Alt3.Docusaurus.PowerShell.psm1

#Region 'PREFIX' 0
Set-StrictMode -Version Latest
$PSDefaultParameterValues['*:ErrorAction'] = 'Stop' # full stop on first error
#EndRegion 'PREFIX'
#Region '.\Private\CreateOrCleanFolder.ps1' 0
function CreateOrCleanFolder() {
    <#
        .SYNOPSIS
            Helper function to create a folder OR remove it's contents if it already exists.
    #>

    param(
        [Parameter(Mandatory = $True)][string]$Path
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    # create the folder if it does not exist
    if (-not(Test-Path -Path $Path)) {
        Write-Verbose "=> creating folder $($Path)"
        New-Item -Path $Path -ItemType Directory -Force

        return
    }

    # otherwise remove it's contents
    Write-Verbose "=> cleaning folder $($Path)"
    Remove-Item -Path (Join-Path -Path $Path -ChildPath *.*)
}
#EndRegion '.\Private\CreateOrCleanFolder.ps1' 24
#Region '.\Private\GetCallerPreference.ps1' 0
function GetCallerPreference {
    <#
    .Synopsis
       Fetches "Preference" variable values from the caller's scope.
    .DESCRIPTION
       Script module functions do not automatically inherit their caller's variables, but they can be
       obtained through the $PSCmdlet variable in Advanced Functions. This function is a helper function
       for any script module Advanced Function; by passing in the values of $ExecutionContext.SessionState
       and $PSCmdlet, GetCallerPreference will set the caller's preference variables locally.
    .PARAMETER Cmdlet
       The $PSCmdlet object from a script module Advanced Function.
    .PARAMETER SessionState
       The $ExecutionContext.SessionState object from a script module Advanced Function. This is how the
       GetCallerPreference function sets variables in its callers' scope, even if that caller is in a different
       script module.
    .PARAMETER Name
       Optional array of parameter names to retrieve from the caller's scope. Default is to retrieve all
       Preference variables as defined in the about_Preference_Variables help file (as of PowerShell 4.0)
       This parameter may also specify names of variables that are not in the about_Preference_Variables
       help file, and the function will retrieve and set those as well.
    .EXAMPLE
       GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
 
       Imports the default PowerShell preference variables from the caller into the local scope.
    .EXAMPLE
       GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState -Name 'ErrorActionPreference','SomeOtherVariable'
 
       Imports only the ErrorActionPreference and SomeOtherVariable variables into the local scope.
    .EXAMPLE
       'ErrorActionPreference','SomeOtherVariable' | GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
 
       Same as Example 2, but sends variable names to the Name parameter via pipeline input.
    .INPUTS
       String
    .OUTPUTS
       None. This function does not produce pipeline output.
    .LINK
       https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d
    #>


    [CmdletBinding(DefaultParameterSetName = 'AllVariables')]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript( { $_.GetType().FullName -eq 'System.Management.Automation.PSScriptCmdlet' })]
        $Cmdlet,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.SessionState]
        $SessionState,

        [Parameter(ParameterSetName = 'Filtered', ValueFromPipeline = $true)]
        [string[]]
        $Name
    )

    begin {
        $filterHash = @{}
    }

    process {
        if ($null -ne $Name) {
            foreach ($string in $Name) {
                $filterHash[$string] = $true
            }
        }
    }

    end {
        # List of preference variables taken from the about_Preference_Variables help file in PowerShell version 4.0

        $vars = @{
            'ErrorView'                     = $null
            'FormatEnumerationLimit'        = $null
            'LogCommandHealthEvent'         = $null
            'LogCommandLifecycleEvent'      = $null
            'LogEngineHealthEvent'          = $null
            'LogEngineLifecycleEvent'       = $null
            'LogProviderHealthEvent'        = $null
            'LogProviderLifecycleEvent'     = $null
            'MaximumAliasCount'             = $null
            'MaximumDriveCount'             = $null
            'MaximumErrorCount'             = $null
            'MaximumFunctionCount'          = $null
            'MaximumHistoryCount'           = $null
            'MaximumVariableCount'          = $null
            'OFS'                           = $null
            'OutputEncoding'                = $null
            'ProgressPreference'            = $null
            'PSDefaultParameterValues'      = $null
            'PSEmailServer'                 = $null
            'PSModuleAutoLoadingPreference' = $null
            'PSSessionApplicationName'      = $null
            'PSSessionConfigurationName'    = $null
            'PSSessionOption'               = $null

            'ErrorActionPreference'         = 'ErrorAction'
            'DebugPreference'               = 'Debug'
            'ConfirmPreference'             = 'Confirm'
            'WhatIfPreference'              = 'WhatIf'
            'VerbosePreference'             = 'Verbose'
            'WarningPreference'             = 'WarningAction'
        }


        foreach ($entry in $vars.GetEnumerator()) {
            if (([string]::IsNullOrEmpty($entry.Value) -or -not $Cmdlet.MyInvocation.BoundParameters.ContainsKey($entry.Value)) -and
                ($PSCmdlet.ParameterSetName -eq 'AllVariables' -or $filterHash.ContainsKey($entry.Name))) {
                $variable = $Cmdlet.SessionState.PSVariable.Get($entry.Key)

                if ($null -ne $variable) {
                    if ($SessionState -eq $ExecutionContext.SessionState) {
                        Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false
                    }
                    else {
                        $SessionState.PSVariable.Set($variable.Name, $variable.Value)
                    }
                }
            }
        }

        if ($PSCmdlet.ParameterSetName -eq 'Filtered') {
            foreach ($varName in $filterHash.Keys) {
                if (-not $vars.ContainsKey($varName)) {
                    $variable = $Cmdlet.SessionState.PSVariable.Get($varName)

                    if ($null -ne $variable) {
                        if ($SessionState -eq $ExecutionContext.SessionState) {
                            Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false
                        }
                        else {
                            $SessionState.PSVariable.Set($variable.Name, $variable.Value)
                        }
                    }
                }
            }
        }

    } # end

} # function GetCallerPreference
#EndRegion '.\Private\GetCallerPreference.ps1' 141
#Region '.\Private\GetCustomEditUrl.ps1' 0
function GetCustomEditUrl() {
    <#
        .SYNOPSIS
            Returns the `custom_edit_url` for the given .md file.
 
        .DESCRIPTION
            Generates a URL pointing to the PowerShell source file that was used to generate the markdown file.
 
        .NOTES
            - passing string `null` will return string `null`
            - URLs for non-monolithic modules point to a .ps1 file with same name as the markdown file
            - URLs for monolithic modules will always point to a .psm1 with same name as passed module
    #>

    param(
        [Parameter(Mandatory = $True)][string]$Module,
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile,
        [Parameter(Mandatory = $False)][string]$EditUrl,
        [switch]$Monolithic
    )

    # return $null so Docusaurus will not render the `Edit this page` button
    if (-not $EditUrl) {
        return
    }

    # if string "null" was passed explicitely, return as-is
    if ($EditUrl -eq "null") {
        return "null"
    }

    # removing trailing slashes
    $EditUrl = $EditUrl.TrimEnd("/")

    # point to the function source file for non-monlithic modules
    if (-not $Monolithic) {
        $command = [System.IO.Path]::GetFileNameWithoutExtension($MarkdownFile)

        return $EditUrl + '/' + $command + ".ps1"
    }

    # point to the module source file for monolithic modules
    if (Test-Path $Module) {
        $Module = [System.IO.Path]::GetFileNameWithoutExtension($Module)
    }

    return $EditUrl + '/' + $Module + ".psm1"
}
#EndRegion '.\Private\GetCustomEditUrl.ps1' 48
#Region '.\Private\IndentLineBelowOpeningBracket.ps1' 0
function IndentLineBelowOpeningBracket() {
    <#
        .SYNOPSIS
            Indent line directly below line with opening curly bracket.
 
        .NOTES
            Because PlatyPS sometimes gets the indentation wrong with complex examples.
 
        .LINK
            https://regex101.com/r/eMCf3E/1
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    Write-Verbose "Removing blank lines above closing curly bracket"

    $content = ReadFile -MarkdownFile $MarkdownFile

    $regex = [regex]::new('({\n)([^\s+].+)')

    $content = $content -replace $regex, "`$1 `$2"

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\IndentLineBelowOpeningBracket.ps1' 29
#Region '.\Private\IndentLineWithOpeningBracket.ps1' 0
function IndentLineWithOpeningBracket() {
    <#
        .SYNOPSIS
            Corrects indentation for lines with opening curly brackets and incorrect indentation
            by comparing indentation of the line below (and recalculating if things are amiss).
 
        .NOTES
            Skips correcting if the line below has 4-space indentation
 
        .NOTES
            The regex gives us three useful matching groups:
            - Group 1 is the full first without the line feed
            - Group 2 is the full second line without the line feed
            - Group 3 contains the leading spaces of the second line
 
        .LINK
            https://regex101.com/r/mT2jLC/1
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    Write-Verbose "Removing blank lines above closing curly bracket"

    $content = ReadFile -MarkdownFile $MarkdownFile

    $regex = [regex]::new('(?m)^([^\s].+{)\n((\s+)(.+))')

    $callback = {
        param($match)

        # do nothing if next line starts with 4 spaces
        if ($match.Groups[3].Value.Length -eq 4) {
            return $match
        }

        # divide spacing of next line by 2 and use that as correct indentation
        [string]$fixedIndentation = ""
        $fixedIndentation.PadRight(($match.Groups[3].Value.Length / 2 - 1), " ")

        $fixedIndentation + $match.Groups[1] + "`n" + $match.Groups[2]
    }

    $content = $regex.replace($content, $callback)

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\IndentLineWithOpeningBracket.ps1' 51
#Region '.\Private\InitializeTempFolder.ps1' 0
function InitializeTempFolder() {
    <#
        .SYNOPSIS
            Creates the temp folder and the `debug.info` file.
 
        .DESCRIPTION
            The temp folder is where all work is done before the enriched mdx files are copied
            to the docusaurus sidebar folder. We use this approach to support future debugging
            as it will be near impossible to reason about bugs without looking at the PlatyPS
            generated source files, knowing which PowerShell version was used etc.
 
        .NOTES
            Ideally, we should also log used module versions for Alt3, PlatyPS and Pester.
    #>

    param(
        [Parameter(Mandatory = $True)][string]$Path
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    # create the folder
    Write-Verbose "Initializing temp folder:"
    CreateOrCleanFolder -Path $Path

    # log the module parameters used for this run
    $ParameterList = (Get-Command -Name New-DocusaurusHelp).Parameters
    $parameterHash = [ordered]@{ }

    $ParameterList.Keys | ForEach-Object {
        $variable = (Get-Variable -Name $_ -ErrorAction SilentlyContinue)

        if ($null -eq $variable) { # Verbose, ErrorAction, etc.
            return
        }

        $parameterHash.Add($_, $variable.Value)
    }

    # create the hash with debug information
    $debugInfo = [ordered]@{
        ModuleParameters = $parameterHash
        PSVersionTable   = $PSVersionTable
    } | ConvertTo-Json -Depth 5

    # create the debug file
    Write-Verbose "=> preparing debug file"
    $debugFile = Join-Path -Path $Path -ChildPath "debug.json"
    $fileEncoding = New-Object System.Text.UTF8Encoding $False

    [System.IO.File]::WriteAllLines($debugFile, $debugInfo, $fileEncoding)
}
#EndRegion '.\Private\InitializeTempFolder.ps1' 52
#Region '.\Private\InsertFinalNewline.ps1' 0
function InsertFinalNewline() {
    <#
        .SYNOPSIS
            Adds a traling newline to the end of the file.
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    $content = ReadFile -MarkdownFile $MarkdownFile

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content ($content + "`n")
}
#EndRegion '.\Private\InsertFinalNewline.ps1' 15
#Region '.\Private\InsertPowerShellMonikers.ps1' 0
function InsertPowerShellMonikers() {
    <#
        .SYNOPSIS
            Adds the `powershell` moniker to all code blocks without a language moniker.
 
        .NOTES
            We need to do this because PlatyPS does (yet) not add the moniker itself
            => https://github.com/PowerShell/platyPS/issues/475
 
        .LINK
            https://regex101.com/r/Jpo9AL/1
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    $content = ReadFile -MarkdownFile $MarkdownFile

    $regex = '(```)\n((?:(?!```)[\s\S])+)(```)\n'

    $content = [regex]::replace($content, $regex, '```powershell' + "`n" + '$2```' + "`n")

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\InsertPowerShellMonikers.ps1' 26
#Region '.\Private\InsertUserMarkdown.ps1' 0
function InsertUserMarkdown() {
    <#
        .SYNOPSIS
            Inserts user provided markdown directly above OR below the PlatyPS generated markdown.
 
        .NOTES
            Will use file content as markdown if $Markdown resolves to a file.
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile,
        [Parameter(Mandatory = $False)][string]$Markdown,
        [Parameter(Mandatory = $True)][ValidateSet('Prepend', 'Append')][string]$Mode
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $content = ReadFile -MarkdownFile $MarkdownFile

    # use file content as markdown
    if (Test-Path $Markdown -ErrorAction SilentlyContinue) {
        $Markdown = Get-Content -Path $Markdown -Raw
    }

    # remove any leading or trailing newlines
    $Markdown = $Markdown.TrimStart()
    $Markdown = $Markdown.TrimEnd()

    # convert CRLF to LF
    $Markdown = $Markdown -replace "`r`n", "`n"

    # insert user markdown
    if ($Mode -eq "Prepend") {
        Write-Verbose "=> prepending user markdown"

        $regex = '(---\n\n)'
        $content = $content -replace $regex, "---`n`n$Markdown`n`n"
    }
    else {
        Write-Verbose "=> appending user markdown"

        $content = "$content`n`n$Markdown`n`n"
    }

    # create new file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\InsertUserMarkdown.ps1' 47
#Region '.\Private\NewMarkdownExample.ps1' 0
function NewMarkdownExample() {
    <#
        .SYNOPSIS
            Generates a new markdown example block.
 
        .NOTES
            PowerShell language monicker inserted by the SetPowerShellMoniker function.
    #>

    param(
        [Parameter(Mandatory = $True)][string]$Header,
        [Parameter(Mandatory = $True)][string]$Code,
        [Parameter(Mandatory = $False)][string]$Description = $null
    )

    $example = "$Header`n"
    $example += '```' + "`n"
    $example += $Code
    $example += '```' + "`n"

    if ([string]::IsNullOrEmpty($Description)) {
        $example += "`n"
    } else {
        $example += "`n$Description`n"
    }

    return $example
}
#EndRegion '.\Private\NewMarkdownExample.ps1' 28
#Region '.\Private\NewSidebarIncludeFile.ps1' 0
function NewSidebarIncludeFile() {
    <#
        .SYNOPSIS
            Generates a `.js` file holding an array with all .mdx 'ids` to be imported in Docusaurus `sidebar.js`.
 
        .LINK
            https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-powershell-1.0/ff730948(v=technet.10)
    #>

    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "Sidebar",
        Justification = 'False positive as rule does not scan child scopes')]
    param(
        [Parameter(Mandatory = $True)][string]$TempFolder,
        [Parameter(Mandatory = $True)][string]$OutputFolder,
        [Parameter(Mandatory = $True)][string]$Sidebar,
        [Parameter(Mandatory = $True)][Object]$MarkdownFiles
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    Write-Verbose "Generating docusaurus.sidebar.js"

    # generate a list of PowerShell commands by stripping .md from the generated PlatyPs files
    [array]$commands = $MarkdownFiles | Select-Object @{ Name = "PowerShellCommand"; Expression = { "'$Sidebar/" + [System.IO.Path]::GetFileNameWithoutExtension($_) + "'" } } | Select-Object  -Expand PowerShellCommand

    # generate content using Here-String block
    $content = @"
/**
 * Import this file in your Docusaurus ``sidebars.js`` file.
 *
 * Auto-generated by Alt3.Docusaurus.PowerShell.
 *
 * Copyright (c) 2019-present, ALT3 B.V.
 *
 * Licensed under the MIT license.
 */
 
module.exports = [
    $($commands -Join ",`n ")
];
"@


    # create the temp file
    $fileName = "docusaurus.sidebar.js"
    $tempFile = Join-Path -Path $tempFolder -ChildPath $fileName
    $fileEncoding = New-Object System.Text.UTF8Encoding $False
    [System.IO.File]::WriteAllLines($tempFile, $content, $fileEncoding)

    # copy to the sidebar folder, convert relative output folder to absolute if needed
    if (-Not([System.IO.Path]::IsPathRooted($OutputFolder))) {
        $outputFolder = Join-Path "$(Get-Location)" -ChildPath $OutputFolder
    }

    Copy-Item -Path $tempFile -Destination (Join-Path -Path $outputFolder -ChildPath $fileName)
}
#EndRegion '.\Private\NewSidebarIncludeFile.ps1' 55
#Region '.\Private\ReadFile.ps1' 0
function ReadFile() {
    <#
        .SYNOPSIS
            Retrieves raw markdown from file.
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    (Get-Content -Path $MarkdownFile.FullName -Raw).TrimEnd()
}
#EndRegion '.\Private\ReadFile.ps1' 12
#Region '.\Private\RemoveEmptyLinesAboveClosingBracket.ps1' 0
function RemoveEmptyLinesAboveClosingBracket() {
    <#
        .SYNOPSIS
            Removes blank lines below lines ending with a closing curly bracket.
 
        .NOTES
            Required so following steps can trust formatting.
 
        .LINK
            https://regex101.com/r/xvEd2O/1
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    Write-Verbose "Removing blank lines above closing curly bracket"

    $content = ReadFile -MarkdownFile $MarkdownFile

    $regex = [regex]::new('(\n\n+\s+}|\n\n})')

    $content = $content -replace $regex, "`n}"

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\RemoveEmptyLinesAboveClosingBracket.ps1' 29
#Region '.\Private\RemoveEmptyLinesBelowOpeningBracket.ps1' 0
function RemoveEmptyLinesBelowOpeningBracket() {
    <#
        .SYNOPSIS
            Removes blank lines below lines ending with an opening curly bracket.
 
        .NOTES
            Required so following steps can trust formatting.
 
        .LINK
            https://regex101.com/r/LlSt8t/1
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    Write-Verbose "Removing blank lines below opening curly bracket"

    $content = ReadFile -MarkdownFile $MarkdownFile

    $regex = [regex]::new('({\n+\n)')

    $content = $content -replace $regex, "{`n"

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\RemoveEmptyLinesBelowOpeningBracket.ps1' 29
#Region '.\Private\RemoveFile.ps1' 0
function RemoveFile() {
    <#
        .SYNOPSIS
            Helper function to remove a file if it exists.
    #>

    param(
        [Parameter(Mandatory = $True)][string]$Path
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    Write-Verbose "=> removing $Path"

    if (Test-Path -Path $Path) {
        Remove-Item -Path $Path -Force
    }
}
#EndRegion '.\Private\RemoveFile.ps1' 18
#Region '.\Private\ReplaceExamples.ps1' 0
function ReplaceExamples() {
    <#
        .SYNOPSIS
            Replace PlatyPS generated code block examples.
 
        .DESCRIPTION
            Replaces custom fenced code blocks and placeholder examples, otherwise uses PlatyPS generated defaults.
 
            See link below for a detailed description of the determination process.
 
        .LINK
            https://github.com/alt3/Docusaurus.PowerShell/issues/14#issuecomment-568552556
    #>

    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "NoPlaceHolderExamples",
        Justification = 'False positive as rule does not scan child scopes')]
    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile,
        [switch]$NoPlaceHolderExamples
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $content = ReadFile -MarkdownFile $MarkdownFile
    [string]$newExamples = ""

    # ---------------------------------------------------------------------
    # extract all EXAMPLE nodes
    # https://regex101.com/r/y4UxP8/7
    # ---------------------------------------------------------------------
    $regexExtractExamples = [regex]'### (EXAMPLE|Example) [0-9][\s\S]*?(?=\n### EXAMPLE|\n## PARAMETERS|$)'
    $examples = $regexExtractExamples.Matches($content)

    if ($examples.Count -eq 0) {
        Write-Warning "Unable to find any EXAMPLE nodes. Please check your Get-Help definitions before filing an issue!"
    }

    # process each EXAMPLE node
    $examples | ForEach-Object {
        $example = $_

        # ---------------------------------------------------------------------
        # do not modify if it's a PlatyPS placeholder example
        # https://regex101.com/r/WOQL0l/4
        # ---------------------------------------------------------------------
        $regexPlatyPlaceholderExample = [regex]::new('{{ Add example code here }}')
        if ($example -match $regexPlatyPlaceholderExample) {

            if ($NoPlaceHolderExamples) {
                Write-Verbose "=> Example 1: PlatyPS Placeholder (dropping)"
                return
            }

            Write-Verbose "=> Example 1: PlatyPS Placeholder (keeping)"
            $newExamples += "$example`n"
            return
        }

        # ---------------------------------------------------------------------
        # PowerShell 6: re-construct Code Fenced example
        # - https://regex101.com/r/lHdZHM/6 => without a description
        # - https://regex101.com/r/CGjQco/3 => with a description
        # ---------------------------------------------------------------------
        $regexPowerShell6TripleCodeFence = [regex]::new('(### EXAMPLE ([0-9|[0-9]+))\n(```\n(```|```ps|```posh|```powershell)\n```\n)\n([\s\S]*?)\\`\\`\\`(\n\n|\n)([\s\S]*|\n)')

        if ($example -match $regexPowerShell6TripleCodeFence) {
            $header = $matches[1]
            $code = $matches[5]
            $description = $matches[7]

            Write-Verbose "=> $($header): Triple Code Fence (PowerShell 6 and lower)"

            $newExample = NewMarkdownExample -Header $header -Code $code -Description $description
            $newExamples += $newExample
            return
        }

        # ---------------------------------------------------------------------
        # PowerShell 7: re-construct PlatyPS Paired Code Fences example
        # - https://regex101.com/r/FRA139/1 => without a description
        # - https://regex101.com/r/YIIwUs/5 => with a description
        # ---------------------------------------------------------------------
        $regexPowerShell7PairedCodeFences = [regex]::new('(### EXAMPLE ([0-9]|[0-9]+))\n(```\n(```|```ps|```posh|```powershell)\n)([\s\S]*?)```\n```(\n\n|\n)([\s\S]*|\n)')

        if ($example -match $regexPowerShell7PairedCodeFences) {
            $header = $matches[1]
            $code = $matches[5]
            $description = $matches[7]

            Write-Verbose "=> $($header): Paired Code Fences (PowerShell 7)"

            $newExample = NewMarkdownExample -Header $header -Code $code -Description $description
            $newExamples += $newExample
            return
        }

        # ---------------------------------------------------------------------
        # PowerShell 7: re-construct non-adjacent Code Fenced example
        # - https://regex101.com/r/kLr98l/3 => without a description
        # - https://regex101.com/r/eJH4cQ/6 => with a complex description
        # ---------------------------------------------------------------------
        $regexPowerShell7NonAdjacentCodeBlock = [regex]::new('(### EXAMPLE ([0-9]|[0-9]+))\n(```\n(```|```ps|```posh|```powershell)\n)([\s\S]*?)\\`\\`\\`(\n\n([\s\S]*)|\n)')

        if ($example -match $regexPowerShell7NonAdjacentCodeBlock) {
            $header = $matches[1]
            $code = $matches[5] -replace ('```' + "`n"), ''
            $description = $matches[7]

            Write-Verbose "=> $($header): Non-Adjacent Code Block (PowerShell 7)"

            $newExample = NewMarkdownExample -Header $header -Code $code -Description $description

            $newExamples += $newExample
            return
        }

        # ---------------------------------------------------------------------
        # no matches so we simply use the unaltered PlatyPS generated example
        # - https://regex101.com/r/rllmTj/1 => without a decription
        # - https://regex101.com/r/kTH75U/1 => with a description
        # ---------------------------------------------------------------------
        $regexPlatyPsDefaults = [regex]::new('(### EXAMPLE ([0-9]|[0-9]+))\n```\n([\s\S]*)```\n([\s\S]*)')

        if ($example -match $regexPlatyPsDefaults) {
            $header = $matches[1]
            $code = $matches[5] -replace ('```' + "`n"), ''
            $description = $matches[7]

            Write-Verbose "=> $($header): PlatyPS Default (all PowerShell versions)"

            $newExamples += "$example`n"
            return
        }

        # we should never reach this point
        Write-Warning "Unsupported code block detected, please file an issue containing the error message below at https://github.com/alt3/Docusaurus.PowerShell/issues"
        Write-Warning $example
    }

    # replace EXAMPLES section in content with updated examples
    # https://regex101.com/r/8OEW0w/1/
    $regex = '## EXAMPLES\n[\s\S]+## PARAMETERS'
    $newExamples = $newExamples.Replace('$', '$$') # Escape $ characters in new examples (https://github.com/alt3/Docusaurus.PowerShell/pull/98)
    $replacement = "## EXAMPLES`n`n$($newExamples)## PARAMETERS"
    $content = [regex]::replace($content, $regex, $replacement)

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\ReplaceExamples.ps1' 149
#Region '.\Private\ReplaceFrontMatter.ps1' 0
function ReplaceFrontMatter() {
    <#
        .SYNOPSIS
            Replaces PlatyPS generated front matter with Docusaurus compatible front matter.
 
        .LINK
            https://www.apharmony.com/software-sagacity/2014/08/multi-line-regular-expression-replace-in-powershell/
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile,
        [Parameter(Mandatory = $False)][string]$CustomEditUrl,
        [Parameter(Mandatory = $False)][string]$MetaDescription,
        [Parameter(Mandatory = $False)][array]$MetaKeywords,
        [switch]$HideTitle,
        [switch]$HideTableOfContents
    )

    $powershellCommandName = [System.IO.Path]::GetFileNameWithoutExtension($markdownFile.Name)

    # prepare front matter
    $newFrontMatter = [System.Collections.ArrayList]::new()
    $newFrontMatter.Add("---") | Out-Null
    $newFrontMatter.Add("id: $($powershellCommandName)") | Out-Null
    $newFrontMatter.Add("title: $($powershellCommandName)") | Out-Null

    if ($MetaDescription) {
        $description = [regex]::replace($MetaDescription, '%1', $powershellCommandName)
        $newFrontMatter.Add("description: $($description)") | Out-Null
    }

    if ($MetaKeywords) {
        $newFrontMatter.Add("keywords:") | Out-Null
        $MetaKeywords | ForEach-Object {
            $newFrontMatter.Add(" - $($_)") | Out-Null
        }
    }
    $newFrontMatter.Add("hide_title: $(if ($HideTitle) {"true"} else {"false"})") | Out-Null
    $newFrontMatter.Add("hide_table_of_contents: $(if ($HideTableOfContents) {"true"} else {"false"})") | Out-Null

    if ($CustomEditUrl) {
        $newFrontMatter.Add("custom_edit_url: $($CustomEditUrl)") | Out-Null
    }

    $newFrontMatter.Add("---") | Out-Null

    # translate front matter to a string and replace CRLF with LF
    $newFrontMatter = ($newFrontMatter| Out-String) -replace "`r`n", "`n"

    # replace front matter
    $content = ReadFile -MarkdownFile $MarkdownFile
    $regex = "(?sm)^(---)(.+)^(---).$\n"
    $content = $content -replace $regex, $newFrontMatter

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\ReplaceFrontMatter.ps1' 57
#Region '.\Private\ReplaceHeader1.ps1' 0
function ReplaceHeader1() {
    <#
        .SYNOPSIS
            Removes the markdown H1 element OR preprends it with an extra newline if the -KeepHeader1 switch is used.
 
        .LINK
            https://regex101.com/r/hnVQvQ/1
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile,
        [switch]$KeepHeader1
    )

    $content = ReadFile -MarkdownFile $MarkdownFile

    $regex = '(---)(\n\n|\n)(# .+)'

    if ($KeepHeader1) {
        $content = $content -replace $regex, ("---`n`n" + '$3') # prepend newline (for first match only)
    } else {
        $content = $content -replace $regex, '---' # remove line (for first match only)
    }

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\ReplaceHeader1.ps1' 27
#Region '.\Private\SeparateMarkdownHeadings.ps1' 0
function SeparateMarkdownHeadings() {
    <#
        .SYNOPSIS
            Adds a blank line after markdown headers IF they are directly followed by an adjacent non-blank lines.
 
        .NOTES
            This ensures the markdown format will match with e.g. Prettier which in turn will
            prevent getting format-change suggestions when running e.g. > Visual Studio Code
            > CTRL+SHIFT+P > Format Document.
 
        .LINK
            https://regex101.com/r/llYF0H/1
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    Write-Verbose "Inserting blank line beneath non-separated headers."

    $content = ReadFile -MarkdownFile $MarkdownFile

    $regex = [regex]::new('(?m)^\n^([#]#{0,5}[a-z]*\s.+)\n(.+)')

    $content = $content -replace $regex, "`n`$1`n`n`$2"

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\SeparateMarkdownHeadings.ps1' 31
#Region '.\Private\SetLfLineEndings.ps1' 0
function SetLfLineEndings() {
    <#
        .SYNOPSIS
            Replaces all CRLF line endings with LF so we can consitently use/expect `n when regexing etc.
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    $content = ReadFile -MarkdownFile $MarkdownFile

    $content = ($content -replace "`r`n", "`n") + "`n"

    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\SetLfLineEndings.ps1' 16
#Region '.\Private\UnescapeSpecialChars.ps1' 0
function UnescapeSpecialChars() {
    <#
        .SYNOPSIS
            Replaces platyPS escaped special chars with the un-escaped version.
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile
    )

    $content = ReadFile -MarkdownFile $MarkdownFile

    # regular
    $content = [regex]::replace($content, '\\`', '`') # backticks: `
    $content = [regex]::replace($content, '\\\[', '[') # square opening brackets: [
    $content = [regex]::replace($content, '\\\]', ']') # square closing brackets: ]

    # specific cases
    $content = [regex]::replace($content, '\\\\\\>', '\>') # as used in eg: PS C:\>

    # replace file
    WriteFile -MarkdownFile $MarkdownFile -Content $content
}
#EndRegion '.\Private\UnescapeSpecialChars.ps1' 23
#Region '.\Private\WriteFile.ps1' 0
function WriteFile() {
    <#
        .SYNOPSIS
            Writes content to a UTF-8 file without BOM using LF as newlines.
    #>

    param(
        [Parameter(Mandatory = $True)][System.IO.FileSystemInfo]$MarkdownFile,
        [Parameter(Mandatory = $True)][string]$Content
    )

    # replace file (UTF-8 without BOM)
    $fileEncoding = New-Object System.Text.UTF8Encoding $False
    [System.IO.File]::WriteAllText($MarkdownFile.FullName, $Content, $fileEncoding)
}
#EndRegion '.\Private\WriteFile.ps1' 15
#Region '.\Public\New-DocusaurusHelp.ps1' 0
function New-DocusaurusHelp() {
    <#
        .SYNOPSIS
            Generates Get-Help documentation in Docusaurus compatible `.mdx` format.
 
        .DESCRIPTION
            The `New-DocusaurusHelp` cmdlet generates Get-Help documentation in "Docusaurus
            compatible" format by creating an `.mdx` file for each command exported by
            the module, enriched with command-specific front matter variables.
 
            Also creates a `sidebar.js` file for simplified integration into the Docusaurus sidebar menu.
 
        .OUTPUTS
            System.Object
 
        .EXAMPLE
            New-DocusaurusHelp -Module Alt3.Docusaurus.PowerShell
 
            This example uses default settings to generate a Get-Help page for each command exported by
            the Alt3.Docusaurus.PowerShell module.
 
        .EXAMPLE
            ```
            $parameters = @{
                Module = "Alt3.Docusaurus.PowerShell"
                DocsFolder = "D:\my-project\docs"
                Sidebar = "commands"
                Exclude = @(
                    "Get-SomeCommand"
                )
                MetaDescription = 'Help page for the PowerShell command "%1"'
                MetaKeywords = @(
                    "PowerShell"
                    "Documentation"
                )
            }
 
            New-DocusaurusHelp @parameters
            ```
 
            This example uses
            [splatting](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_splatting)
            to override default settings.
 
            See the list of Parameters below for all available overrides.
 
        .PARAMETER Module
            Specifies the module this cmdlet will generate Docusaurus documentation for.
 
            You may specify a module name, a `.psd1` file or a `.psm1` file.
 
        .PARAMETER DocsFolder
            Specifies the absolute or relative **path** to the Docusaurus `docs` folder.
 
            Optional, defaults to `docusaurus/docs`, case sensitive.
 
        .PARAMETER Sidebar
            Specifies the **name** of the docs subfolder in which the `.mdx` files will be created.
 
            Optional, defaults to `commands`, case sensitive.
 
        .PARAMETER Exclude
            Optional array with command names to exclude.
 
        .PARAMETER MetaDescription
            Optional string that will be inserted into Docusaurus front matter to be used as html meta tag 'description'.
 
            If placeholder `%1` is detected in the string, it will be replaced by the command name.
 
        .PARAMETER MetaKeywords
            Optional array of keywords inserted into Docusaurus front matter to be used as html meta tag `keywords`.
 
        .PARAMETER PrependMarkdown
            Optional string containing raw markdown **OR** path to a markdown file.
 
            Markdown will be inserted in all pages, directly above the PlatyPS generated markdown.
 
        .PARAMETER AppendMarkdown
            Optional string containing raw markdown **OR** path to a markdown file.
 
            Markdown will be inserted in all pages, directly below the PlatyPS generated markdown.
 
        .PARAMETER EditUrl
            Specifies the URL prefixed to all Docusaurus `custom_edit_url` front matter variables.
 
            Optional, defaults to `null`.
 
        .PARAMETER KeepHeader1
            By default, the `H1` element will be removed from the PlatyPS generated markdown because
            Docusaurus uses the per-page frontmatter variable `title` as the page's H1 element instead.
 
            You may use this switch parameter to keep the markdown `H1` element, most likely in
            combination with the `HideTitle` parameter.
 
        .PARAMETER HideTitle
            Sets the Docusaurus front matter variable `hide_title`.
 
            Optional, defaults to `false`.
 
        .PARAMETER HideTableOfContents
            Sets the Docusaurus front matter variable `hide_table_of_contents`.
 
            Optional, defaults to `false`.
 
        .PARAMETER NoPlaceholderExamples
            By default, Docusaurus will generate a placeholder example if your Get-Help
            definition does not contain any `EXAMPLE` nodes.
 
            You can use this switch to disable that behavior which will result in an empty `EXAMPLES` section.
 
        .PARAMETER Monolithic
            Use this optional parameter if the PowerShell module source is monolithic.
 
            Will point all `custom_edit_url` front matter variables to the `.psm1` file.
 
        .PARAMETER VendorAgnostic
            Use this switch parameter if you **do not want to use Docusaurus** but would still like
            to benefit of the markdown-enrichment functions this module provides.
 
            If used, the `New-GetDocusaurusHelp` command will produce the exact same markdown as
            always but will skip the following two Docusaurus-specific steps:
 
            - PlatyPS frontmatter will not be touched
            - `docusaurus.sidebar.js` file will not be generated
 
            For more information please
            [visit this page](https://docusaurus-powershell.netlify.app/docs/faq/vendor-agnostic).
 
        .NOTES
            For debugging purposes, Docusaurus.PowerShell creates a local temp folder with:
 
            - the raw PlatyPS generated `.md` files
            - the Docusaurus.PowerShell enriched `.mdx` files
            - a `debug.json` file containing detailed module information
 
            ```powershell
            $tempFolder = Get-Item (Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "Alt3.Docusaurus.PowerShell")
            ```
 
        .LINK
            https://docusaurus-powershell.netlify.app/
 
        .LINK
            https://docusaurus.io/
 
        .LINK
            https://github.com/PowerShell/platyPS
    #>

    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $True)][string]$Module,
        [Parameter(Mandatory = $False)][string]$DocsFolder = "docusaurus/docs",
        [Parameter(Mandatory = $False)][string]$Sidebar = "commands",
        [Parameter(Mandatory = $False)][array]$Exclude = @(),
        [Parameter(Mandatory = $False)][string]$EditUrl,
        [Parameter(Mandatory = $False)][string]$MetaDescription,
        [Parameter(Mandatory = $False)][array]$MetaKeywords,
        [Parameter(Mandatory = $False)][string]$PrependMarkdown,
        [Parameter(Mandatory = $False)][string]$AppendMarkdown,
        [switch]$KeepHeader1,
        [switch]$HideTitle,
        [switch]$HideTableOfContents,
        [switch]$NoPlaceHolderExamples,
        [switch]$Monolithic,
        [switch]$VendorAgnostic
    )

    GetCallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    # make sure the passed module is valid
    if (Test-Path($Module)) {
        Import-Module $Module -Force -Global
        $Module = [System.IO.Path]::GetFileNameWithoutExtension($Module)
    }

    if (-Not(Get-Module -Name $Module)) {
        $Module = $Module
        throw "New-DocusaurusHelp: Specified module '$Module' is not loaded"
    }

    $moduleName = [io.path]::GetFileName($module)

    # markdown for the module will be copied into the sidebar subfolder
    Write-Verbose "Initializing sidebar folder:"
    $sidebarFolder = Join-Path -Path $DocsFolder -ChildPath $Sidebar
    CreateOrCleanFolder -Path $sidebarFolder

    # create tempfolder used for generating the PlatyPS files and creating the mdx files
    $tempFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "Alt3.Docusaurus.PowerShell" | Join-Path -ChildPath $moduleName
    InitializeTempFolder -Path $tempFolder

    # generate PlatyPs markdown files
    Write-Verbose "Generating PlatyPS files."
    New-MarkdownHelp -Module $Module -OutputFolder $tempFolder -Force | Out-Null

    # remove files matching excluded commands
    Write-Verbose "Removing excluded files:"
    $Exclude | ForEach-Object {
        RemoveFile -Path (Join-Path -Path $tempFolder -ChildPath "$($_).md")
    }

    # rename PlatyPS files and create an `.mdx` copy we will transform
    Write-Verbose "Cloning PlatyPS files."
    Get-ChildItem -Path $tempFolder -Filter *.md | ForEach-Object {
        $platyPsFile = $_.FullName -replace '\.md$', '.PlatyPS.md'
        $mdxFile = $_.FullName -replace '\.md$', '.mdx'
        Move-Item -Path $_.FullName -Destination $platyPsFile
        Copy-Item  -Path $platyPsFile -Destination $mdxFile
    }

    # update all remaining mdx files to make them Docusaurus compatible
    Write-Verbose "Updating mdx files."
    $mdxFiles = Get-ChildItem -Path $tempFolder -Filter *.mdx

    ForEach ($mdxFile in $mdxFiles) {
        Write-Verbose "Processing $($mdxFile.Name):"

        # prepare per-page variables
        $customEditUrl = GetCustomEditUrl -Module $Module -MarkdownFile $mdxFile -EditUrl $EditUrl -Monolithic:$Monolithic

        $frontMatterArgs = @{
            MarkdownFile = $mdxFile
            MetaDescription = $metaDescription
            CustomEditUrl = $customEditUrl
            MetaKeywords = $metaKeywords
            HideTitle = $HideTitle
            HideTableOfContents = $HideTableOfContents
        }

        # transform the markdown using these steps (overwriting the mdx file per step)
        SetLfLineEndings -MarkdownFile $mdxFile

        if (-not($VendorAgnostic)) {
            ReplaceFrontMatter @frontmatterArgs
        }

        ReplaceHeader1 -MarkdownFile $mdxFile -KeepHeader1:$KeepHeader1

        if ($PrependMarkdown) {
            InsertUserMarkdown -MarkdownFile $mdxFile -Markdown $PrependMarkdown -Mode "Prepend"
        }

        ReplaceExamples -MarkdownFile $mdxFile -NoPlaceholderExamples:$NoPlaceholderExamples

        if ($AppendMarkdown) {
            InsertUserMarkdown -MarkdownFile $mdxFile -Markdown $AppendMarkdown -Mode "Append"
        }

        # Post-fix complex multiline code examples (https://github.com/pester/Pester/issues/2195)
        RemoveEmptyLinesBelowOpeningBracket -MarkdownFile $mdxFile
        RemoveEmptyLinesAboveClosingBracket -MarkdownFile $mdxFile
        IndentLineBelowOpeningBracket -MarkdownFile $mdxFile
        IndentLineWithOpeningBracket -MarkdownFile $mdxFile

        ## Continue with general enrichment
        InsertPowerShellMonikers -MarkdownFile $mdxFile
        UnescapeSpecialChars -MarkdownFile $mdxFile
        SeparateMarkdownHeadings -MarkdownFile $mdxFile
        InsertFinalNewline -MarkdownFile $mdxFile
    }

    # copy updated mdx files to the target folder
    Write-Verbose "Copying mdx files to sidebar folder."
    Get-ChildItem -Path $tempFolder -Filter *.mdx | ForEach-Object {
        Copy-Item  -Path $_.FullName -Destination (Join-Path -Path $sidebarFolder -ChildPath ($_.Name))
    }

    # generate the `.js` file used for the docusaurus sidebar
    if (-not($VendorAgnostic)) {
        NewSidebarIncludeFile -MarkdownFiles $mdxFiles -TempFolder $tempFolder -OutputFolder $sidebarFolder -Sidebar $Sidebar
    }

    # output Get-ChildItem so end-user can post-process generated files as they see fit
    Get-ChildItem -Path $sidebarFolder
}
#EndRegion '.\Public\New-DocusaurusHelp.ps1' 276