Friday, November 9, 2007

PowerShell syntax highlighting with HTML

When I decided to start this blog, I thought it would be nice to be able to display PowerShell code examples with nice formatting and syntax highlighting. I tried a few freely available tools out there that advertised PowerShell syntax support, but they all seemed to fall short in a category or two. None of them correctly handled multiple line strings or here-strings, and none of them correctly highlighted PowerShell variables enclosed in curly braces e.g. "${this is a variable}".

I thought it would be fun to try to write my own syntax highlighting tool with PowerShell. It was a little more difficult than I originally thought it would be, but it really was fun.

The script takes a string parameter that can be a code snippet or a path to a PowerShell script file. A switch parameter can be provided if line numbers are wanted in the output. The script highlights strings, comments, operators, numbers, keywords (including things kind of like keywords), types (specifically the shortcut types available in PowerShell, like [string] and [regex]), variables, and Cmdlet names. The colors used to highlight each of these items, along with the background color, default foreground color, and line number color can be customized by changing the values of the variables declared at the top of the script.

Here is the script (highlighted with itself):

# Highlight-Syntax.ps1
# version 1.0
# by Jeff Hillman
#
# this script uses regular expressions to highlight PowerShell
# syntax with HTML.

param[string] $code, [switch] $LineNumbers )

if ( Test-Path $code -ErrorAction SilentlyContinue )
{
    $code = Get-Content $code | Out-String
}

$backgroundColor = "#DDDDDD"
$foregroundColor = "#000000"
$stringColor     = "#800000"
$commentColor    = "#008000"
$operatorColor   = "#C86400"
$numberColor     = "#800000"
$keywordColor    = "#C86400"
$typeColor       = "#404040"
$variableColor   = "#000080"
$cmdletColor     = "#C86400"
$lineNumberColor = "#404040"

filter Html-Encode( [switch] $Regex )
{
    # some regular expressions operate on strings that have already
    # been through this filter, so the patterns need to be updated
    # to look for the encoded characters instead of the literal ones.
    # we do it with this filter instead of directly in the regular 
    # expression so the expressions can be a bit more readable (ha!)

    $_ = $_ -replace "&", "&"
    
    if ( $Regex )
    {
        $_ = $_ -replace "(?<!\(\?)<", "&lt;"
        $_ = $_ -replace "(?<!\(\?)>", "&gt;"
    }
    else
    {
        $_ = $_ -replace "\t", "    "
        $_ = $_ -replace " ", "&nbsp;"
        $_ = $_ -replace "<", "&lt;"
        $_ = $_ -replace ">", "&gt;"
    }
    
    $_
}

# regular expressions

$operatorRegex =  @"
((?x:
 (?# assignment operators)
 =|\+=|-=|\*=|/=|%=|
 (?# arithmatic operators)
 (?<!\de)
 (\+|-|\*|/|%)(?![a-z])|
 (?# unary operators)
 \+\+|\-\-|
 (?# logical operators)
 (-and|-or|-not)\b|!|
 (?# bitwise operators)
 (-band|-bor)\b|
 (?# redirection and pipeline operators)
 2>>|>>|2>&1|1>&2|2>|>|<|\||
 (?# comparison operators)
 (
  -[ci]? (?# case and case-insensitive variants)
  (eq|ne|ge|gt|lt|le|like|notlike|match|notmatch|replace|contains|notcontains)\b
 )|
 (?# type operators)
 (-is|-isnot|-as)\b|
 (?# range and miscellaneous operators)
 \.\.|(?<!\d)\.(?!\d)|&|::|:|,|``|
 (?# string formatting operator)
 -f\b
))
"@ | Html-Encode -Regex

$numberRegex = @"
((?x:
 (
  (?# hexadecimal numbers)
  (\b0x[0-9a-f]+)|
  (?# regular numbers)
  (?<!&)
  ((\b[0-9]+(\.(?!\.))?[0-9]*)|((?<!\.)\.[0-9]+))
  (?!(>>|>&[12]|>))
  (?# scientific notation)
  (e(\+|-)?[0-9]+)?
 )
 (
  (?# type specifiers)
  (l|ul|u|f|ll|ull)?
  (?# size shorthand)
  (b|kb|mb|gb)?
  \b
 )?
))
"@ | Html-Encode -Regex

$keyWordRegex = @"
((?x:
 \b(
 (?# don't match anything that looks like a variable or a parameter)
 (?<![-$])
 (
  (?# condition keywords)
  if|else|elseif|(?<!\[)switch(?!\])|
  (?# loop keywords)
  for|(?<!\|</span>&nbsp;)foreach(?!-object)|in|do|while|until|default|break|continue|
  (?# scope keywords)
  global|script|local|private|
  (?# block keywords)
  begin|process|end|
  (?# other keywords)
  function|filter|param|throw|trap|return
 )
 )\b
))
"@

$typeRegex = @"
((?x:
 \[
 (
  (?# primitive types and arrays of those types)
  ((int|long|string|char|bool|byte|double|decimal|float|single)(\[\])?)|
  (?# other types)
  regex|array|xml|scriptblock|switch|hashtable|type|ref|psobject|wmi|wmisearcher|wmiclass
 )
 \]
))
"@

$cmdletNames = Get-Command -Type Cmdlet | Foreach-Object { $_.Name }

function Highlight-Other( [string] $code )
{
    $highlightedCode = $code | Html-Encode
    
    # operators
    $highlightedCode = $highlightedCode -replace 
        $operatorRegex, "<span style='color: $operatorColor'>`$1</span>"

    # numbers
    $highlightedCode = $highlightedCode -replace 
        $numberRegex, "<span style='color: $numberColor'>`$1</span>"

    # keywords
    $highlightedCode = $highlightedCode -replace 
        $keyWordRegex, "<span style='color: $keywordColor'>`$1</span>"

    # types
    $highlightedCode = $highlightedCode -replace 
        $typeRegex, "<span style='color: $typeColor'>`$1</span>"

    # Cmdlets
    $cmdletNames | Foreach-Object {
        $highlightedCode = $highlightedCode -replace 
            "\b($_)\b", "<span style='color: $cmdletColor'>`$1</span>"
    }

    $highlightedCode
}

$RegexOptions = [System.Text.RegularExpressions.RegexOptions]

$highlightedCode = ""

# we treat variables, strings, and comments differently because we don't 
# want anything inside them to be highlighted.  we combine the regular 
# expressions so they are mutually exclusive

$variableRegex = '(\$(\w+|{[^}`]*(`.[^}`]*)*}))'

$stringRegex = @"
(?x:
 (?# here strings)
 @[`"'](.|\n)*?^[`"']@|
 (?# double-quoted strings)
 `"[^`"``]*(``.[^`"``]*)*`"|
 (?# single-quoted strings)
 '[^'``]*(``.[^'``]*)*'
)
"@

$commentRegex = "#[^\r\n]*"

[regex]::Matches( $code, 
                  "(?<before>(.|\n)*?)" + 
                  "((?<variable>$variableRegex)|" + 
                  "(?<string>$stringRegex)|" + 
                  "(?<comment>$commentRegex))",
                  $RegexOptions::MultiLine ) | Foreach-Object {
    # highlight everything before the variable, string, or comment    
    $highlightedCode += Highlight-Other $_.Groups[ "before" ].Value

    if ( $_.Groups[ "variable" ].Value )
    {
        $highlightedCode += 
            "<span style='color: $variableColor'>" + 
            ( $_.Groups[ 'variable' ].Value | Html-Encode ) + 
            "</span>"
    }
    elseif ( $_.Groups[ "string" ].Value )
    {
        $string = $_.Groups[ 'string' ].Value | Html-Encode
        
        $string = "<span style='color: $stringColor'>$string</span>"

        # we have to highlight each piece of multi-line strings
        if ( $string -match "\r\n" )
        {
            # highlight any line continuation characters as operators
            $string = $string -replace 
                "(``)(?=\r\n)", "<span style='color: $operatorColor'>``</span>"

            $string = $string -replace 
                "\r\n", "</span>`r`n<span style='color: $stringColor'>"
        }

        $highlightedCode += $string
    }
    else
    {
        $highlightedCode += 
            "<span style='color: $commentColor'>" + 
            $( $_.Groups[ 'comment' ].Value | Html-Encode ) + 
            "</span>"
    }

    # we need to keep track of the last position of a variable, string, 
    # or comment, so we can highlight everything after it
    $lastMatch = $_
}

if ( $lastMatch )
{
    # highlight everything after the last variable, string, or comment   
    $highlightedCode += Highlight-Other $code.SubString( $lastMatch.Index + $lastMatch.Length )
}
else
{
    $highlightedCode = Highlight-Other $code
}

# add line breaks
$highlightedCode = 
    [regex]::Replace( $highlightedCode, '(?=\r\n)', '<br />', $RegexOptions::MultiLine )

# put the highlighted code in the pipeline
"<div style='width: 100%; " + 
            "/*height: 100%;*/ " +
            "overflow: auto; " +
            "font-family: Consolas, `"Courier New`", Courier, mono; " +
            "font-size: 12px; " +
            "background-color: $backgroundColor; " +
            "color: $foregroundColor; " + 
            "padding: 2px 2px 2px 2px; white-space: nowrap'>"

if ( $LineNumbers )
{
    $digitCount = 
        ( [regex]::Matches( $highlightedCode, "^", $RegexOptions::MultiLine ) ).Count.ToString().Length

    $highlightedCode = [regex]::Replace( $highlightedCode, "^", 
        "<li style='color: $lineNumberColor; padding-left: 5px'><span style='color: $foregroundColor'>",
        $RegexOptions::MultiLine )

    $highlightedCode = [regex]::Replace( $highlightedCode, "<br />", "</span><br />",
        $RegexOptions::MultiLine )
    
    "<ol start='1' style='border-left: " +
                         "solid 1px $lineNumberColor; " +
                         "margin-left: $( ( $digitCount * 10 ) + 15 )px; " +
                         "padding: 0px;'>"
}

$highlightedCode

if ( $LineNumbers )
{
    "</ol>"
}

"</div>"


As you might have guessed, most of the work with this script was getting the regular expressions right. I have always loved the support for regular expressions offered by the .Net Framework, and PowerShell makes them even easier to use. It turns out that I was able to reuse the expressions in a grammar file for my new favorite text editor, Intype. I like that my code examples look absolutely identical to what I see in my editor.

The script obviously relies heavily on these regular expressions, which can contribute to a higher potential for problems, but it seems to do a pretty good job. With all of the matching and string processing, the script can also be fairly slow.

Then along came the CTP for Windows PowerShell 2.0. One of the new classes available to developers is the System.Management.Automation.PsParser class, which can be used to tokenize PowerShell code. As you might imagine, a task like highlighting syntax becomes much easier.

Below is an equivalent highlighting script that makes use of the System.Management.Automation.PsParser class. It is used in the same way as the PowerShell version 1.0 script.

#requires -version 2.0

# Highlight-Syntax.ps1
# version 2.0
# by Jeff Hillman
#
# this script uses the System.Management.Automation.PsParser class
# to highlight PowerShell syntax with HTML.

param( [string] $code, [switch] $LineNumbers )

if ( Test-Path $code -ErrorAction SilentlyContinue )
{
    $code = Get-Content $code | Out-String
}

$backgroundColor = "#DDDDDD"
$foregroundColor = "#000000"
$lineNumberColor = "#404040"

$PSTokenType = [System.Management.Automation.PSTokenType]

$colorHash = @{ 
#    $PSTokenType::Unknown            = $foregroundColor; 
    $PSTokenType::Command            = "#C86400";
#    $PSTokenType::CommandParameter   = $foregroundColor;
#    $PSTokenType::CommandArgument    = $foregroundColor;
    $PSTokenType::Number             = "#800000";
    $PSTokenType::String             = "#800000";
    $PSTokenType::Variable           = "#000080";
#    $PSTokenType::Member             = $foregroundColor;
#    $PSTokenType::LoopLabel          = $foregroundColor;
#    $PSTokenType::Attribute          = $foregroundColor;
    $PSTokenType::Type               = "#404040";
    $PSTokenType::Operator           = "#C86400";
#    $PSTokenType::GroupStart         = $foregroundColor;
#    $PSTokenType::GroupEnd           = $foregroundColor;
    $PSTokenType::Keyword            = "#C86400";
    $PSTokenType::Comment            = "#008000";
    $PSTokenType::StatementSeparator = "#C86400";
#    $PSTokenType::NewLine            = $foregroundColor;
    $PSTokenType::LineContinuation   = "#C86400";
#    $PSTokenType::Position           = $foregroundColor;
    
}

filter Html-Encode
{
    $_ = $_ -replace "&", "&amp;"
    $_ = $_ -replace " ", "&nbsp;"
    $_ = $_ -replace "<", "&lt;"
    $_ = $_ -replace ">", "&gt;"

    $_
}

# replace the tabs with spaces
$code = $code -replace "\t", ( " " * 4 )

if ( $LineNumbers )
{
    $highlightedCode = "<li style='color: $lineNumberColor; padding-left: 5px'>"
}
else
{
    $highlightedCode = ""
}

$parser = [System.Management.Automation.PsParser]
$lastColumn = 1
$lineCount = 1

foreach ( $token in $parser::Tokenize( $code, [ref] $null ) | Sort-Object StartLine, StartColumn )
{
    # get the color based on the type of the token
    $color = $colorHash[ $token.Type ]
    
    if ( $color -eq $null ) 
    { 
        $color = $foregroundColor
    }

    # add whitespace
    if ( $lastColumn -lt $token.StartColumn )
    {
        $highlightedCode += ( "&nbsp;" * ( $token.StartColumn - $lastColumn ) )
    }

    switch ( $token.Type )
    {
        $PSTokenType::String {
            $string = "<span style='color: {0}'>{1}</span>" -f $color, 
                ( $code.SubString( $token.Start, $token.Length ) | Html-Encode )

            # we have to highlight each piece of multi-line strings
            if ( $string -match "\r\n" )
            {
                # highlight any line continuation characters as operators
                $string = $string -replace "(``)(?=\r\n)", 
                    ( "<span style='color: {0}'>``</span>" -f $colorHash[ $PSTokenType::Operator ] )

                $stringHtml = "</span><br />`r`n"
                
                if ( $LineNumbers )
                {
                     $stringHtml += "<li style='color: $lineNumberColor; padding-left: 5px'>"
                }

                $stringHtml += "<span style='color: $color'>"

                $string = $string -replace "\r\n", $stringHtml
            }

            $highlightedCode += $string
            break
        }

        $PSTokenType::NewLine {
            $highlightedCode += "<br />`r`n"
            
            if ( $LineNumbers )
            {
                $highlightedCode += "<li style='color: $lineNumberColor; padding-left: 5px'>"
            }
            
            $lastColumn = 1
            ++$lineCount
            break
        }

        default {
            if ( $token.Type -eq $PSTokenType::LineContinuation )
            {
                $lastColumn = 1
                ++$lineCount
            }

            $highlightedCode += "<span style='color: {0}'>{1}</span>" -f $color, 
                ( $code.SubString( $token.Start, $token.Length ) | Html-Encode )
        }
    }

    $lastColumn = $token.EndColumn
}

# put the highlighted code in the pipeline
"<div style='width: 100%; " + 
            "/*height: 100%;*/ " +
            "overflow: auto; " +
            "font-family: Consolas, `"Courier New`", Courier, mono; " +
            "font-size: 12px; " +
            "background-color: $backgroundColor; " +
            "color: $foregroundColor; " + 
            "padding: 2px 2px 2px 2px; white-space: nowrap'>"

if ( $LineNumbers )
{
    $digitCount =  $lineCount.ToString().Length

    "<ol start='1' style='border-left: " +
                         "solid 1px $lineNumberColor; " +
                         "margin-left: $( ( $digitCount * 10 ) + 15 )px; " +
                         "padding: 0px;'>"
}

$highlightedCode

if ( $LineNumbers )
{
    "</ol>"
}

"</div>"


Besides being much faster, the PsParser technique provides much more potential for customization. This script highlights the same types of things as the 1.0 version of the script, but other token types are available, including CommandParameter, CommandArgument (these two types would be very difficult to define with a regular expression), and Member. All of the token types are listed in the script; those that I ignore are commented out.

As an extra bonus, here is a little script that highlights PowerShell commands in the console:

# Highlight-Commands.ps1
# by Jeff Hillman
#
# this script highlights PowerShell commands with HTML.

param[string] $commands )

$backgroundColor = "#000000"
$foregroundColor = "#FFC400"

filter Html-Encode( [switch] $Regex )
{
    $_ = $_ -replace "&", "&amp;"
    $_ = $_ -replace "\t", "    "
    $_ = $_ -replace " ", "&nbsp;"
    $_ = $_ -replace "<", "&lt;"
    $_ = $_ -replace ">", "&gt;"
    
    $_
}

# add line breaks
$highlightedCommands = $commands | Html-Encode

$highlightedCommands = [regex]::Replace( $highlightedCommands, "^", 
    "<span style='font-weight: bold;'>",
    [System.Text.RegularExpressions.RegexOptions]::MultiLine )

$highlightedCommands = [regex]::Replace( $highlightedCommands, "(?=\r\n)", "</span><br />",
    [System.Text.RegularExpressions.RegexOptions]::MultiLine )


# put the highlighted commands in the pipeline
"<div style='width: 100%; " + 
            "/*height: 100%;*/ " +
            "overflow: auto; " +
            "font-family: `"Courier New`", Courier, mono; " +
            "font-size: 12px; " +
            "background-color: $backgroundColor; " +
            "color: $foregroundColor; " + 
            "padding: 2px 2px 2px 2px; white-space: nowrap'>"

$highlightedCommands

"</div>"

C:\Users\hillman\Documents\WindowsPowerShell\Utilities

PSH$ ls


    Directory: Microsoft.PowerShell.Core\FileSystem::C:\Users\hillman\Documents\WindowsPowerShell\Utilities


Mode                LastWriteTime     Length Name
----                -------------     ------ ----
-a---         07-Nov-07   4:05 PM      38117 Compile-Help.ps1
-a---         10-Nov-07   2:53 PM       8047 Highlight-1.0Syntax.ps1
-a---         10-Nov-07   3:09 PM       5182 Highlight-2.0Syntax.ps1
-a---         10-Nov-07   3:27 PM       1296 Highlight-Commands.ps1
-a---         09-Nov-07   2:49 PM      14741 Utilities.ps1


Well, I hope these scripts come in handy for someone else out there.

Thursday, November 1, 2007

PowerShell and Subversion

I try to use PowerShell for everything I can. Because we use Subversion for source control at the shop where I work, I have written a few PowerShell functions to make life a little easier when using Subversion at the command line.

Some might argue that most of this stuff can be done for you by TortoiseSVN or something similar. That may be true, but where's the fun in that? I was using TortoiseSVN when I first started playing around with PowerShell, but I found that using it forced me to keep a Windows Explorer window open a lot, which kept me away from the command line. I wanted to force myself to use PowerShell for as much as possible, so I uninstalled TortoiseSVN and I've never looked back.

The first function is called Get-SvnStatus. It uses the Subversion "status" command with the "--xml" switch and displays the status of versioned files and directories little more nicely. More importantly, the Status and Path are properties are on the objects output by this function. This means they can be used farther down the pipeline.

function Get-SvnStatus( [string] $filter = "^(?!unversioned)", [switch] $NoFormat )
{
    # powershell chokes on "wc-status" and doesn't like two definitions of "item"
    [xml]$status = ( ( svn status --xml ) -replace "wc-status", "svnstatus" ) `
        -replace "item=", "itemstatus="
    
    $statusObjects = $status.status.target.entry | Where-Object { 
        $_.svnstatus.itemstatus -match $filter 
    } | Foreach-Object {
        $_ | Select-Object @{ Name = "Status"; Expression = { $_.svnstatus.itemstatus } }, 
                           @{ Name = "Path";   Expression = { Resolve-Path $_.path } }
    } | Sort-Object Status
    
    if ( $NoFormat )
    {
        $statusObjects
    }
    else
    {
        $statusObjects | Format-Table -Auto
    }
}


D:\Subversion\projects

PSH$ Get-SvnStatus

Status   Path
------   ----
modified D:\Subversion\projects\KickButtApp\KickButtApp.cpp
modified D:\Subversion\projects\KickButtApp\KickButtApp.h


The filter can be any regular expression to match against the status of the item. The default filter doesn't allow unversioned files through. The "NoFormat" switch is there in case the Status or Path properties of the objects created need to be used down the pipeline.

The next function is Compare-SvnRevision. It uses Subversion's "cat" command to get a copy of a file at a specified revision to compare with your current working copy. The default value for the revision is "HEAD", which will get the latest version in the repository.

function Compare-SvnRevision( [string] $path, [string] $revision = "HEAD" )
{
    $url = Get-SvnUrl $path

    $fileInfo = New-Object System.IO.FileInfo $path

    svn cat -r $revision $url > "TEMP - $($fileInfo.Name)"

    WinMerge $path "TEMP - $($fileInfo.Name)"

    $winMerge = Get-Process WinMerge

    while ( $winMerge -eq $null )
    {
        $winMerge = Get-Process WinMerge
    }

    $winMerge.WaitForExit()

    Remove-Item "TEMP - $($fileInfo.Name)"
}


This function uses WinMerge to perform the comparison, which is my favorite two-way merge tool. It also assumes WinMerge is in $env:Path.

The next function, Resolve-SvnConflicts, uses Get-SvnStatus to get all the files in a "conflicted" state after an update, commit, or merge. It then uses DiffMerge to do a three-way merge of the base revision, your working copy, and the head revision. You are prompted to indicate if you were able to resolve conflicts, and if you have, the "resolved" command is performed on the file. This function assumes DiffMerge is in $env:Path.

function Resolve-SvnConflicts
{
    Get-SvnStatus "conflicted" -NoFormat | Foreach-Object { 
        $file = ( Resolve-Path $_.Path )

        Write-Output "Merging $( $file )..."

        $baseRevision, $headRevision = ( Get-ChildItem "$file.r*" | Sort-Object )

        DiffMerge /t1 "Base Revision" /t2 "Working Copy" /t3 "Head Revision" `
            $baseRevision, "$file.mine", $headRevision

        $diffMerge = Get-Process DiffMerge

        while ( $diffMerge -eq $null )
        {
            $diffMerge = Get-Process DiffMerge
        }

        $diffMerge.WaitForExit()

        Write-Output "Conflicts resolved? [yes, no]"

        $resolved = Read-Host

        if ( $resolved -imatch "^y(es)?$" )
        {
            Copy-Item "$file.mine" $file -Force
            svn resolved $file
        }
    }
}


These next two functions just use the Subversion "info" command with the "--xml" switch to get the URL or revision for a versioned file. They both have a switch parameter to indicate if you want the result to be put on the clipboard. To put these items on the clipboard, I use a Cmdlet I wrote myself, but the PowerShell Community Extensions have a Cmdlet with the same name that will do the same thing and, apparently, more.

function Get-SvnUrl( [string] $path = ".", [switch] $Clipboard )
{
    $url = ( [xml]( svn info --xml $path ) ).info.entry.url

    if ( $Clipboard )
    {
        Set-Clipboard $url  
    }

    $url
}

function Get-SvnRevision( [string] $path = ".", [switch] $Clipboard )
{
    $revision = ( [xml]( svn info --xml $path ) ).info.entry.revision

    if ( $Clipboard )
    {
        Set-Clipboard $revision  
    }

    $revision
}


Well, there you have it. I use these functions every day, so I hope sharing them will make someone else's life a little easier.

Friday, October 12, 2007

PowerShell Help

I personally think the help that comes with Windows PowerShell is very good. I love having access to everything right from the command line. Accessing the help from the command line just falls short in convenience if you would like to switch quickly between more than one topic, or if the help topic you are reading is particularly long.

Inspired in part by the Windows PowerShell Graphical Help File, I wrote a PowerShell script that uses the PowerShell XML help files to generate HTML help topics that are then compiled into a CHM with Html Help Workshop. The advantage of the resulting file over the Windows PowerShell Graphical Help File is that help is generated for all Cmdlets installed on your system, not just the core Cmdlets that come with Windows PowerShell. The help manual also includes help for PSProviders and all of the "about" topics. The original formatting for the "about" topics is preserved, so they don't look quite as nice as the other topics. I tested this after installing the PowerShell Community Extensions and the PowerShell cmdlets for Active Directory by Quest Software, even though I personally don't use either snap-in. I would be interested to hear how the help looks for any other snap-ins available out there.

The resulting CHM is fully searchable, and all Cmdlet and "about" topic names link to their topic, so jumping from one topic to another is much easier.
The script generates all of the HTML topic files, a CSS file, an Html Help Contents file, and an Html Help Project file. The CHM is automatically compiled at the end of the script, but it would be fairly easy to update these files and then re-create the manual if you don't, for example, like the color scheme I chose.
Here is the script:

# Compile-Help.ps1
# by Jeff Hillman
#
# this script uses the text and XML PowerShell help files to generate HTML help
# for all PowerShell Cmdlets, PSProviders, and "about" topics.  the help topics 
# are compiled into a .chm file using HTML Help Workshop.

param[string] $outDirectory = ".\PSHelp", [switch] $GroupByPSSnapIn )

function Html-Encode( [string] $value )
{
    # System.Web.HttpUtility.HtmlEncode() doesn't quite get everything, and 
    # I don't want to load the System.Web assembly just for this.  I'm sure 
    # I missed something here, but these are the characters I saw that needed 
    # to be encoded most often
    $value = $value -replace "&(?![\w#]+;)", "&amp;"
    $value = $value -replace "<(?!!--)", "&lt;"
    $value = $value -replace "(?<!--)>", "&gt;"
    $value = $value -replace "’", "&#39;"
    $value = $value -replace '["“”]', "&quot;"
    
    $value = $value -replace "\n", "<br />"

    $value
}

function Capitalize-Words( [string] $value )
{
    $capitalizedString = ""

    # convert the string to lower case and split it into individual words. for each one,
    # capitalize the first character, and append it to the converted string
    [regex]::Split( $value.ToLower(), "\s" ) | ForEach-Object {
        $capitalizedString += ( [string]$_.Chars( 0 ) ).ToUpper() + $_.SubString( 1 ) + " "
    }

    $capitalizedString.Trim()
}

function Get-ParagraphedHtml( [string] $xmlText )
{
    $value = ""
    
    if ( $xmlText -match "<(\w+:)?para" )
    {
        $value = ""
        $options = [System.Text.RegularExpressions.RegexOptions]::Singleline

        foreach ( $match in [regex]::Matches( $xmlText, 
            "<(?:\w+:)?para[^>]*>(?<Text>.*?)</(?:\w+:)?para>", $options ) )
        {
            $value += "<p>$( Html-Encode $match.Groups[ 'Text' ].Value )</p>"    
        }
    }
    else
    {
        $value = Html-Encode $xmlText
    }
    
    $value
}

function Get-SyntaxHtml( [xml] $syntaxXml )
{
    $syntaxHtml = ""

    # generate the HTML for each form of the Cmdlet syntax
    foreach ( $syntaxItem in $syntaxXml.syntax.syntaxItem )
    {
        if ( $syntaxHtml -ne "" )
        {
            $syntaxHtml += "<br /><br />`n"
        }

        $syntaxHtml += "        $( $syntaxItem.name.get_InnerText().Trim() ) "

        if ( $syntaxItem.parameter )
        {
            foreach ( $parameter in $syntaxItem.parameter )
            {
                $required = [bool]::Parse( $parameter.required )

                $syntaxHtml += "<nobr>[-$( $parameter.name.get_InnerText().Trim() )"

                if ( $required )
                {
                    $syntaxHtml += "]"
                }

                if ( $parameter.parameterValue )
                {
                    $syntaxHtml += 
                        " &lt;$( $parameter.parameterValue.get_InnerText().Trim() )&gt;"
                }

                if ( !$required )
                {
                    $syntaxHtml += "]"
                }

                $syntaxHtml += "</nobr> "
            }
        }

        $syntaxHtml += " <nobr>[&lt;CommonParameters&gt;]</nobr>"
    }

    $syntaxHtml.Trim()
}

function Get-ParameterHtml( [xml] $parameterXml )
{
    $parameterHtml = ""

    # generate HTML for each parameter
    foreach ( $parameter in $parameterXml.parameters.parameter )
    {
        if ( $parameterHtml -ne "" )
        {
            $parameterHtml += "        <br /><br />`n"
        }

        $parameterHtml += 
            "        <nobr><span class=`"boldtext`">-$( $parameter.name.get_InnerText().Trim() )"

        if ( $parameter.parameterValue )
        {
            $parameterHtml += " &lt;$( $parameter.parameterValue.get_InnerText().Trim() )&gt;"
        }

        $parameterHtml += "</span></nobr>`n"

        $parameterHtml += @"
        <br />
        <div id="contenttext">
          $( Get-ParagraphedHtml $parameter.description.get_InnerXml().Trim() )

"@
        if ( $parameter.possibleValues )
        {
            foreach ( $possibleValue in $parameter.possibleValues.possibleValue )
            {
                $parameterHtml += @"
          $( $possibleValue.value.Trim() )<br />

"@
                if ( $possibleValue.description.get_InnerText().Trim() -ne "" )
                {
                    $parameterHtml += @"
          <div id="contenttext">
            $( Get-ParagraphedHtml $possibleValue.description.get_InnerXml().Trim() )
          </div>

"@
                }
            }
        }
        
        $parameterHtml += @"
        <br />
        </div>
        <table class="parametertable">
          <tr>
            <td>Required</td>
            <td>$( $parameter.required )</td>
          </tr>
          <tr>
            <td>Position</td>
            <td>$( $parameter.position )</td>
          </tr>
          <tr>
            <td>Accepts pipeline input</td>
            <td>$( $parameter.pipelineInput )</td>
          </tr>
          <tr>
            <td>Accepts wildcard characters</td>
            <td>$( $parameter.globbing )</td>
          </tr>

"@

        if ( $parameter.defaultValue )
        {
            if$parameter.defaultValue.get_InnerText().Trim() -ne "" )
            {
                $parameterHtml += @"
          <tr>
            <td>Default Value</td>
            <td>$( $parameter.defaultValue.get_InnerText().Trim() )</td>
          </tr>

"@
            }
        }

        $parameterHtml += @"
        </table>

"@
    }

    if ( $parameterHtml -ne "" )
    {
        $parameterHtml += "        <br /><br />`n"
    }

    $parameterHtml += @"
        <nobr><span class="boldtext">&lt;CommonParameters&gt;</span></nobr>
        <br />
        <div id="contenttext">
          <p>
            For more information about common parameters, type "Get-Help about_commonparameters".
          </p>
        </div>

"@

    $parameterHtml.Trim()
}

function Get-InputHtml( [xml] $inputXml )
{
    $inputHtml = ""
    $inputCount = 0

    # generate HTML for each input type
    foreach ( $inputType in $inputXml.inputTypes.inputType )
    {
        if ( $inputHtml -ne "" )
        {
            $inputHtml += "        <br /><br />`n"
        }

        if ( $inputType.type.name.get_InnerText().Trim() -ne "" -or 
            $inputType.type.description.get_InnerText().Trim() -ne "" )
        {
            $inputHtml += "      $( $inputType.type.name.get_InnerText().Trim() )`n"
            $inputHtml += @"
      <div id="contenttext">
        $( Get-ParagraphedHtml $inputType.type.description.get_InnerXml().Trim() )
      </div>

"@
            $inputCount++
        }
    }

    $inputHtml.Trim()
    $inputCount
}

function Get-ReturnHtml( [xml] $returnXml )
{
    $returnHtml = ""
    $returnCount = 0

    # generate HTML for each return value
    foreach ( $returnValue in $returnXml.returnValues.returnValue )
    {
        if ( $returnHtml -ne "" )
        {
            $returnHtml += "        <br /><br />`n"
        }

        if ( $returnValue.type.name.get_InnerText().Trim() -ne "" -or 
            $returnValue.type.description.get_InnerText().Trim() -ne "" )
        {
            $returnHtml += "      $( $returnValue.type.name.get_InnerText().Trim() )`n"
            $returnHtml += @"
      <div id="contenttext">
        $( Get-ParagraphedHtml $returnValue.type.description.get_InnerXml().Trim() )
      </div>

"@
            $returnCount++
        }
    }

    $returnHtml.Trim()
    $returnCount
}

function Get-ExampleHtml( [xml] $exampleXml )
{
    $exampleHtml = ""
    $exampleTotalCount = 0
    $exampleCount = 0

    foreach ( $example in $exampleXml.examples.example )
    {
        $exampleTotalCount++
    }

    # generate HTML for each example
    foreach ( $example in $exampleXml.examples.example )
    {
        if ( $example.code -and $example.code.get_InnerText().Trim() -ne "" )
        {
            if ( $exampleHtml -ne "" )
            {
                $exampleHtml += "        <br />`n"
            }
    
            if ( $exampleTotalCount -gt 1 )
            {
                $exampleHtml += 
                    "        <nobr><span class=`"boldtext`">Example $( $exampleCount + 1 )</span></nobr>`n"
            }
    
            $exampleCodeHtml = "$( Html-Encode $example.introduction.get_InnerText().Trim() )" + 
                "$( Html-Encode $example.code.get_InnerText().Trim() )"
            
            $foundFirstPara = $false
    
            if ( $example.remarks.get_InnerXml() -notmatch "-----------" )
            {
                $exampleHtml += "        <div class=`"syntaxregion`">$exampleCodeHtml</div>`n"

                foreach ( $para in $example.remarks.para )
                {
                    if ( $para.get_InnerText().Trim() -ne "" )
                    {
                        # the first para is generally the description of the example.
                        # other para tags usually contain sample output
                        if ( !$foundFirstPara )
                        {
                            $exampleHtml += @"
        <div id="contenttext">
          <p>
            $( Html-Encode $para.get_InnerText().Trim() )
          </p>
        </div>

"@
                            $foundFirstPara = $true
                        }
                        else
                        {
                            $exampleHtml += @"
        <pre class="syntaxregion">$( $( ( Html-Encode $para.get_InnerText().Trim() )  -replace "<br />", "`n" ) )</pre>

"@
                        }
                    }
                }
            }
            else
            {
                $descriptionHtml = ""
                $getDivider = $false
                $getDescription = $false
                
                foreach ( $para in $example.remarks.para )
                {
                    if ( $para.get_InnerText().Trim() -ne "" )
                    {
                        if ( $para.get_InnerText().Trim() -eq "Description" )
                        {
                            $getDivider = $true
                        }
                        elseif$getDivider )
                        {
                            $getDivider = $false
                            $getDescription = $true
                        }
                        elseif$getDescription )
                        {
                            $descriptionHtml = ( Html-Encode $para.get_InnerText().Trim() )
                            $getDescription = $false
                        }
                        else
                        {
                            $exampleCodeHtml += ( Html-Encode $para.get_InnerText().Trim() -replace "<br />", "`n" )
                        }
                    }
                }
                
                $exampleHtml += "        <div class=`"syntaxregion`">$exampleCodeHtml</div>`n"
                $exampleHtml += @"
        <div id="contenttext">
          <p>
            $descriptionHtml
          </p>
        </div>

"@
            }
    
            $exampleCount++
        }
    }

    $exampleHtml.Trim()
    $exampleCount
}

function Get-TaskExampleHtml( [xml] $exampleXml )
{
    $exampleHtml = ""
    $exampleCount = 0
    $exampleTotalCount = 0

    foreach ( $example in $exampleXml.examples.example )
    {
        $exampleTotalCount++
    }

    # generate HTML for each example
    foreach ( $example in $exampleXml.examples.example )
    {
        if ( $exampleHtml -ne "" )
        {
            $exampleHtml += "        <br />`n"
        }

        if ( $exampleTotalCount -gt 1 )
        {
            $exampleHtml += "        <nobr><span class=`"boldtext`">Example $( $exampleCount + 1 )</span></nobr>`n"
        }

        $exampleHtml += "        <div>$( Get-ParagraphedHtml $example.introduction.get_InnerXml().Trim() )</div>`n"
        
        $exampleCodeHtml = ( Html-Encode $example.code.Trim() ) -replace "<br />", "`n"

        $exampleHtml += "        <pre class=`"syntaxregion`">$exampleCodeHtml</pre>"

        $exampleHtml += "        <div>$( Get-ParagraphedHtml $example.remarks.get_InnerXml().Trim() )</div>`n"

        $exampleCount++
    }

    $exampleHtml.Trim()
}

function Get-LinkHtml( [xml] $linkXml )
{
    $linkHtml = ""
    $linkCount = 0
    $helpTopicNames = $helpTopicNameArray | Foreach-Object { $_.ToUpper() }

    # generate HTML for each related link
    foreach ( $navigationLink in $linkXml.relatedLinks.navigationLink )
    {
        if ( $navigationLink.linkText )
        {
            if ( $navigationLink.linkText -is [object[]] )
            {
                foreach ( $linkText in $navigationLink.linkText )
                {
                    if ( $helpTopicNames -contains $linkText.Trim().ToUpper() )
                    {
                        $linkHtml += "        $( $linkText.Trim() )<br />`n"
                        $linkCount++
                    }
                }
            }
            elseif ( $helpTopicNames -contains $navigationLink.linkText.Trim().ToUpper() )
            {
                $linkHtml += "        $( $navigationLink.linkText.Trim() )<br />`n"
                $linkCount++
            }
        }
    }

    $linkHtml.Trim()
    $linkCount
}

function Get-TaskHtml( [xml] $taskXml )
{
    $taskHtml = ""
    $taskCount = 0

    foreach ( $task in $taskXml.tasks.task )
    {
        if ( $taskHtml -ne "" )
        {
            $taskHtml += "        <br />`n"
        }

        $taskHtml += "        <nobr><span class=`"boldtext`">Task:</span> $( $task.title.Trim() )</nobr>`n"
        
        $taskDescriptionHtml = ( Get-ParagraphedHtml $task.description.get_InnerXml().Trim() )
        
        $taskHtml += "        <div id=`"contenttext`">$taskDescriptionHtml</div>`n"

        # add the example sections
        if ( $task.examples )
        {
            $taskHtml += @"
        <div id="contenttext">
          <p>
            $( Get-TaskExampleHtml ( [xml]$task.examples.get_OuterXml() ) )
          </p>
        </div>
    
"@
        }

        $taskCount++
    }
    
    $taskHtml.Trim()
    $taskCount
}

function Get-DynamicParameterHtml( [xml] $dynamicParameterXml )
{
    $dynamicParameterHtml = ""
    
    # generate HTML for each dynamic parameter
    foreach ( $dynamicParameter in $dynamicParameterXml.dynamicparameters.dynamicparameter )
    {
        $dynamicParameterHtml += "        <nobr><span class=`"boldtext`">-$( $dynamicParameter.name.Trim() )"

        if ( $dynamicParameter.type )
        {
            $dynamicParameterHtml += " &lt;$( $dynamicParameter.type.name.Trim() )&gt;"
        }

        $dynamicParameterHtml += "</span></nobr>`n"

        $dynamicParameterHtml += @"
        <br />
        <div id="contenttext">
          <p>
            $( Html-Encode $dynamicParameter.description.Trim() )
          </p>

"@
        if ( $dynamicParameter.possiblevalues )
        {
            foreach ( $possibleValue in $dynamicParameter.possiblevalues.possiblevalue )
            {
                $dynamicParameterHtml += @"
          <div id="contenttext">
            <span class=`"boldtext`">$( $possibleValue.value )</span>
            <div id="contenttext">
              $( Get-ParagraphedHtml $possibleValue.description.get_InnerXml().Trim() )
            </div>
          </div>

"@
            }
        }

        $dynamicParameterHtml += @"
          <br />
          <span class=`"boldtext`">Cmdlets Supported</span>
          <div id="contenttext">
            <p>
              $( Html-Encode $dynamicParameter.cmdletsupported.Trim() )
            </p>
          </div>
        </div>
        <br />

"@
    }

    $dynamicParameterHtml.Trim()
}

function Write-AboutTopic( [string] $topicName, [string] $topicPath )
{
    # just dump the contents of the about topic exactly as it is.  the only changes needed
    # are to encode the special HTML characters and add topic links
    $topicHtml = @"
<html>
  <head>
    <link rel="stylesheet" type="text/css" href="powershell.css" />
    <title>About $( Capitalize-Words ( $topicName -replace "(about)?_", " " ).Trim() )</title>
  </head>
  <body>
    <div id="topicheading">
      <div id="topictitle">PowerShell Help</div>
      About $( Capitalize-Words ( $topicName -replace "(about)?_", " " ).Trim() )
    </div>
    <pre>
$( ( Html-Encode ( [string]::Join( [Environment]::NewLine, ( Get-Content -Path $topicPath ) ) ) ) -replace "<br />" )
    </pre>
  </body>
</html>
"@

    $topicHtml = Add-Links $topicName $topicHtml

    Out-File -FilePath "$outDirectory\Topics\$topicName.html" -Encoding Ascii -Input $topicHtml
}

function Write-ProviderTopic( [string] $providerFullName, [xml] $providerXml )
{
    $providerName = $providerXml.providerhelp.Name.Trim()
    
    $topicHtml = @"
<html>
  <head>
    <link rel="stylesheet" type="text/css" href="powershell.css" />
    <title>$providerName Help</title>
  </head>
  <body>
    <div id="topicheading">
      <div id="topictitle">PowerShell Help</div>
      $providerName Provider
      <div style="text-align: right; padding-right: 3px;">
         $( $providerFullName -replace "^\w+\." )
      </div>
    </div>
    <div class="categorytitle">Drives</div>
    <div id="contenttext">
      $( Get-ParagraphedHtml $providerXml.providerhelp.drives.get_InnerXml().Trim() )
    </div>
    <div class="categorytitle">Synopsis</div>
    <div id="contenttext">
      <p>$( Html-Encode $providerXml.providerhelp.synopsis.Trim() )</p>
    </div>

"@
    
    $topicHtml += @"
    <div class="categorytitle">Description</div>
    <div id="contenttext">
      $( Get-ParagraphedHtml $providerXml.providerhelp.detaileddescription.get_InnerXml().Trim() )
    </div>

"@

    if ( $providerXml.providerhelp.capabilities.get_InnerText().Trim() -ne "" )
    {
        $topicHtml += @"
    <div class="categorytitle">Capabilities</div>
    <div id="contenttext">
      $( Get-ParagraphedHtml $providerXml.providerhelp.capabilities.get_InnerXml().Trim() )
    </div>

"@
    }

    $taskHtml, $taskCount = Get-TaskHtml( $providerXml.providerhelp.tasks.get_OuterXml() )
    
    if ( $taskCount -gt 0 )
    {
        $topicHtml += @"
    <div class="categorytitle">Task$( if ( $taskCount -gt 1 ) { "s" } )</div>
    <div id="contenttext">
      $taskHtml
    </div>

"@
    }

    if ( $providerXml.providerhelp.dynamicparameters )
    {
        $topicHtml += @"
    <div class="categorytitle">Dynamic Parameters</div>
    <div id="contenttext">
      $( Get-DynamicParameterHtml( $providerXml.providerhelp.dynamicparameters.get_OuterXml() ) )
    </div>

"@
    }

    if ( $providerXml.providerhelp.notes.Trim() -ne "" )
    {
        $topicHtml += @"
    <div class="categorytitle">Notes</div>
    <div id="contenttext">
      <p>$( Html-Encode $providerXml.providerhelp.notes.Trim() )</p>
    </div>

"@
    }
    
    if ( $providerXml.providerhelp.relatedlinks -is [string] )
    {
        $topicHtml += @"
    <div class="categorytitle">Related Links</div>
    <div id="contenttext">
      <p>$( Html-Encode $providerXml.providerhelp.relatedlinks.Trim() )</p>
    </div>
"@ 
    }

    $topicHtml += @"
    <br />
  </body>
</html>    
"@    

    $topicHtml = Add-Links $providerName $topicHtml

    Out-File -FilePath "$outDirectory\Topics\$providerFullName.html" -Encoding Ascii -Input $topicHtml
}

function Write-CmdletTopic( [string] $cmdletFullName, [xml] $cmdletXml )
{
    $cmdletName = $cmdletXml.command.details.name.Trim()
    
    # add the heading, syntax section, and description
    $topicHtml = @"
<html>
  <head>
    <link rel="stylesheet" type="text/css" href="powershell.css" />
    <title>$cmdletName Help</title>
  </head>
  <body>
    <div id="topicheading">
      <div id="topictitle">PowerShell Help</div>
      $cmdletName Cmdlet
      <div style="text-align: right; padding-right: 3px;">
         $( $cmdletFullName -replace "^\w+-\w+\." )
      </div>
    </div>
    <div class="categorytitle">Synopsis</div>
    <div id="contenttext">
      $( Get-ParagraphedHtml $cmdletXml.command.details.description.get_InnerXml().Trim() )
    </div>
    <div class="categorytitle">Syntax</div>
    <div id="contenttext">
      <div class="syntaxregion">$( Get-SyntaxHtml ( [xml]$cmdletXml.command.syntax.get_OuterXml() ) )</div>
    </div>
    <div class="categorytitle">Description</div>
    <div id="contenttext">
      $( Get-ParagraphedHtml $cmdletXml.command.description.get_InnerXml().Trim() )
    </div>

"@

    # add the parameters section
    if ( $cmdletXml.command.parameters )
    {
        $topicHtml += @"
    <div class="categorytitle">Parameters</div>
    <div id="contenttext">
      <p>
        $( Get-ParameterHtml ( [xml]$cmdletXml.command.parameters.get_OuterXml() ) )
      </p>
    </div>

"@
    }
    else
    {
        $topicHtml += @"
    <div class="categorytitle">Parameters</div>
    <div id="contenttext">
      <p>
       <nobr><span class="boldtext">&lt;CommonParameters&gt;</span></nobr><br />
       <div id="contenttext">
         <p>
            For more information about common parameters, type "Get-Help about_commonparameters".
         </p>
        </div>
      </p>
    </div>

"@
    }

    # add the input types section
    if ( $cmdletXml.command.inputTypes )
    {
        $inputHtml, $inputCount = Get-InputHtml ( [xml]$cmdletXml.command.inputTypes.get_OuterXml() )
    
        if ( $inputCount -gt 0 )
        {
            $topicHtml += @"
    <div class="categorytitle">Input Type$( if ( $inputCount -gt 1 ) { "s" } )</div>
    <div id="contenttext">
      $inputHtml
    </div>

"@
        }
    }

    # add the return values section
    if ( $cmdletXml.command.returnValue )
    {
        $returnHtml, $returnCount = Get-ReturnHtml ( [xml]$cmdletXml.command.returnValues.get_OuterXml() )
    
        if ( $returnCount -gt 0 )
        {
            $topicHtml += @"
    <div class="categorytitle">Return Value$( if ( $returnCount -gt 1 ) { "s" } )</div>
    <div id="contenttext">
      $returnHtml
    </div>

"@
        }
    }

    # add the notes section
    if ( $cmdletXml.command.alertSet )
    {
        if ( $cmdletXml.command.alertSet.get_InnerText().Trim() -ne "" )
        {
            $topicHtml += @"
    <div class="categorytitle">Notes</div>
    <div id="contenttext">
      $( Get-ParagraphedHtml $cmdletXml.command.alertSet.get_InnerXml().Trim() )
    </div>

"@
        }
    }

    # add the example section
    if ( $cmdletXml.command.examples )
    {
        $exampleHtml, $exampleCount = Get-ExampleHtml ( [xml]$cmdletXml.command.examples.get_OuterXml() )

        if ( $exampleCount -gt 0 )
        {
            $topicHtml += @"
    <div class="categorytitle">Example$( if ( $exampleCount -gt 1 ) { "s" } )</div>
    <div id="contenttext">
      <p>
        $exampleHtml
      </p>
    </div>

"@
        }
    }

    # add the related links section
    if ( $cmdletXml.command.relatedLinks )
    {
        $linkHtml, $linkCount = Get-LinkHtml ( [xml]$cmdletXml.command.relatedLinks.get_OuterXml() )

        if ( $linkCount -gt 0 )
        {
            $topicHtml += @"
    <div class="categorytitle">Related Link$( if ( $linkCount -gt 1 ) { "s" } )</div>
    <div id="contenttext">
      <p>
        $linkHtml
      </p>
    </div>
    <br />

"@
        }
        else
        {
            $topicHtml +=  "        <br />`n"
        }
    }
    else
    {
        $topicHtml +=  "        <br />`n"
    }

    $topicHtml += @"
  </body>
</html>
"@

    $topicHtml = Add-Links $cmdletName $topicHtml

    Out-File -FilePath "$outDirectory\Topics\$cmdletFullName.html" -Encoding Ascii -Input $topicHtml
}

function Add-Links( [string] $topicName, [string] $topicHtml )
{
    # we only want to add links for Cmdlets and about topics
    $helpHash.Keys | Where-Object { $_ -match "(^\w+-\w+|^about_)" } | Foreach-Object {
        $searchText = $_
    
        # keys representing Cmdlets are formatted like this:
        # <Cmdlet Name>.<PSProvider name>
        if ( $_ -match "^\w+-\w+" )
        {
            # we only want to search for the Cmdlet name
            $searchText = $matches0 ]
        }

        # if the search text isn't the topic being processed
        if ( $searchText -ne $topicName )
        {
            $topicHtml = $topicHtml -replace "\b($searchText)\b", "<a href=`"Topics\$_.html`"><nobr>`$1</nobr></a>"
        }
    }

    $topicHtml
}

# file dumping functions

function Write-Hhp
{
    # write the contents of the Html Help Project file
    Out-File -FilePath "$outDirectory\powershell.hhp" -Encoding Ascii -Input @"
[OPTIONS]
Binary TOC=Yes
Compatibility=1.1 or later
Compiled file=PowerShell.chm
Contents file=powershell.hhc
Default topic=Topics/default.html
Full-text search=Yes
Language=0x409 English (United States)
Title=PowerShell Help

[INFOTYPES]
"@
}

function Write-DefaultPage
{
    $defaultHtml =  @"
<html>
  <head>
    <link rel="stylesheet" type="text/css" href="powershell.css" />
    <title>PowerShell Help</title>
  </head>
  <body style="margin: 5px 5px 5px 5px; color: #FFFFFF; background-color: #C86400;">
    <h2>Windows PowerShell Help</h2>
    <br />
    This complied help manual contains the help for all of the built-in PowerShell Cmdlets 
    and PSProviders, as well as the help for any Cmdlets or PSProviders added through 
    Add-PSSnapin, if help for them is available.  Also included are all of the "about" topics.
    <br /><br />
    To use this manual from the PowerShell command line, add the following function and 
    alias to your PowerShell profile:
    <div id="contenttext">
      <pre class="syntaxregion">function Get-CompiledHelp( [string] `$topic )
{
    if ( `$topic )
    {
        # Get-Command will fail if the topic is a PSProvider or an "about" topic.
        `$ErrorActionPreference = "SilentlyContinue"

        # we don't want Get-Command to resolve to an application or a function 
        `$command = Get-Command `$topic | Where-Object { `$_.CommandType -match "Alias|Cmdlet" }

        # if the topic is an alias or a Cmdlet, combine its name with
        # its PSProvider to get the full name of the help file
        if ( `$command -and `$command.CommandType -eq "Alias" )
        {
            `$topic = "`$( `$command.ResolvedCommand ).`$( `$command.ReferencedCommand.PSSnapIn.Name )"
        }
        elseif ( `$command -and `$command.CommandType -eq "Cmdlet" )
        {
            `$topic = "`$( `$command.Name ).`$( `$command.PSSnapIn.Name )"
        }
        else
        {
            # check to see if we have a PSProvider
            `$psProvider = Get-PSProvider `$topic

            if ( `$psProvider )
            {
                `$topic = "`$( `$psProvider.Name ).`$( `$psProvider.PSSnapIn.Name )"
            }
        }

        hh.exe "mk:@MSITStore:$( Resolve-Path "$outDirectory" )\PowerShell.chm::/Topics/`$topic.html"
    }
    else
    {
        hh.exe "$( Resolve-Path "$outDirectory" )\PowerShell.chm"
    }
}

Set-Alias chelp Get-CompiledHelp</pre>
    </div>
    <br />
    The path in the Get-CompliedHelp function corresponds to the location where this compiled 
    help manual was originally created.  If this file is moved to another location, the path 
    in the function will need to be updated.
    <br />
    <br />
    To view the help topic for Get-ChildItem, type the following:
    <div id="contenttext">
      <div class="syntaxregion">PS$ Get-CompiledHelp Get-ChildItem</div>
    </div>
    <br />
    Because "ls" is an alias for Get-ChildItem, and "chelp" is an alias for Get-CompliedHelp, the following also works:
    <div id="contenttext">
      <div class="syntaxregion">PS$ chelp ls</div>
    </div>
  </body>
</html>
"@

    $defaultHtml = Add-Links "" $defaultHtml

    Out-File -FilePath "$outDirectory\Topics\default.html" -Encoding Ascii -Input $defaultHtml
}

function Write-Css
{
    Out-File -FilePath "$outDirectory\powershell.css" -Encoding Ascii -Input @"
body
{
  margin: 0px 0px 0px 0px;
  padding: 0px 0px 0px 0px;
  font-family: Verdana, Arial, Helvetica, sans-serif;
  font-size: 70%;
  width: 100%;
}

div#topicheading
{
  position: relative;
  left: 0px;
  padding: 5px 0px 5px 10px;
  border-bottom: 1px solid #999999;
  color: #FFFFFF;
  background-color: #C86400;
  font-size: 110%;
  font-weight: bold;
  text-align: left;
}

div#topictitle
{
  padding: 5px 5px 5px 5px;
  color: #FFFFFF
  font-size: 90%;
  font-weight: normal;
}

div#contenttext
{
  top: 0px;
  padding: 0px 25px 0px 25px;
}

p { margin: 5px 0px 5px 0px; }

a:link    { color: #0000FF; }
a:visited { color: #0000FF; }
a:hover   { color: #3366FF; }

table.parametertable
{
  margin-left: 25px;
  font-size: 100%;
  border-collapse:collapse
}

table.parametertable td
{
  font-size: 100%;
  border: solid #999999 1px;
  padding: 0in 5.4pt 0in 5.4pt
}

pre.syntaxregion, div.syntaxregion
{
  background: #DDDDDD;
  padding: 4px 8px;
  cursor: text;
  margin-top: 1em;
  margin-bottom: 1em;
  margin-left: .6em;
  color: #000000;
  border-width: 1px;
  border-style: solid;
  border-color: #999999;
}

.categorytitle
{
  padding-top: .8em;
  font-size: 110%;
  font-weight: bold;
  text-align: left;
  margin-left: 5px;
}

.boldtext { font-weight: bold; }
"@
}

### main ###

# create the topics directory
New-Item -Type Directory -Path "$outDirectory" -Force | Out-Null
New-Item -Type Directory -Path "$outDirectory\Topics" -Force | Out-Null

"`nRetrieving help content...`n"

# initialize variables for HHC file
$hhcContentsHtml = ""
$cmdletCategoryHtml = ""
$cmdletCategoryHash = @{}

# help content hash
$helpHash = @{}
$helpTopicNameArray = @()

# get the Cmdlet help
Get-PSSnapIn | Sort-Object -Property Name | Foreach-Object { 
    $psSnapInName = $_.Name
    
    $helpFilePath = Join-Path $_.ApplicationBase ( ( Get-Command -PSSnapIn $_ ) | Select-Object -First 1 ).HelpFile
    
    # the culture needs to be added to the path on Vista    
    if ( !Test-Path $helpFilePath ) )
    {
        $helpFilePath = "$( $_.ApplicationBase )\$( $Host.CurrentUICulture.Name )\$( Split-Path -Leaf $helpFilePath )"
    }

    if ( Test-Path $helpFilePath )
    {
        $helpXml = [xml]Get-Content $helpFilePath )
    
        $cmdletCategoryContents = ""
    
        Get-Command -PSSnapIn $_ | Foreach-Object {
            $commandName = $_.Name
    
            $helpXml.helpitems.command | Where-Object { 
                $_.details.name -and $_.details.name.Trim() -imatch "\b$commandName\b" 
            } | Foreach-Object {
                # add the Xml Help of the Cmdlet to the help hashtable
                $helpHash"{0}.{1}" -f $commandName, $psSnapInName ] = $_.get_OuterXml()
                $helpTopicNameArray += $commandName

                $cmdletTopicItem = @"
          <li><object type="text/sitemap">
            <param name="Name" value="$commandName">
            <param name="Local" value="Topics\$( "{0}.{1}" -f $commandName, $psSnapInName ).html">
          </object>

"@
                if ( $GroupByPSSnapIn )
                {    
                    $cmdletCategoryContents += $cmdletTopicItem
                }
                else
                {
                    # save the topics so they can be sorted properly and added to the HHC later
                    $cmdletCategoryHash"{0}.{1}" -f $commandName, $psSnapInName ] = $cmdletTopicItem
                }
            }
        } 
    
        if ( $GroupByPSSnapIn )
        {
            # add a category in the HHC for this PSSnapIn and its Cmdlets
            $cmdletCategoryHtml += @"
        <li><object type="text/sitemap">
          <param name="Name" value="$psSnapInName">
        </object>
        <ul>
          $( $cmdletCategoryContents.Trim() )
        </ul>

"@
        }
    }
}

# sort the Cmdlets so they are added to the HHC in a logical order
if ( !$GroupByPSSnapIn )
{
    $cmdletCategoryHash.Keys | Sort-Object | Foreach-Object {
        $cmdletCategoryHtml += $cmdletCategoryHash$_ ]
    }
}

# add the Cmdlet category to the HHC
$hhcContentsHtml += @"
      <li><object type="text/sitemap">
        <param name="Name" value="Cmdlet Help">
      </object>
      <ul>
        $( $cmdletCategoryHtml.Trim() )
      </ul>

"@

$providerCategoryHtml = ""
$providerCategoryHash = @{}

# get the PSProvider help
Get-PSSnapIn | Sort-Object -Property Name | Foreach-Object {
    $psSnapInName = $_.Name

    $helpFilePath = Join-Path $_.ApplicationBase ( ( Get-Command -PSSnapIn $_ ) | Select-Object -First 1 ).HelpFile

    # the culture needs to be added to the path on Vista    
    if ( !Test-Path $helpFilePath ) )
    {
        $helpFilePath = "$( $_.ApplicationBase )\$( $Host.CurrentUICulture.Name )\$( Split-Path -Leaf $helpFilePath )"
    }

    if ( Test-Path $helpFilePath )
    {
        $helpXml = [xml]Get-Content $helpFilePath )
        
        $providerCategoryContents = ""

        Get-PSProvider | Where-Object { $_.PSSnapin.Name -eq $psSnapInName } | Foreach-Object {
            $psProviderName = $_.Name

            $helpXml.helpitems.providerhelp | 
            Where-Object { $_.name.Trim() -imatch "\b$psProviderName\b" } | 
            Foreach-Object {
                $helpHash"{0}.{1}" -f $psProviderName, $psSnapInName ] = $_.get_OuterXml()
                $helpTopicNameArray += $psProviderName
    
                # add a category in the HHC for this PSProvider
                $providerTopicItem = @"
        <li><object type="text/sitemap">
          <param name="Name" value="$psProviderName">
          <param name="Local" value="Topics\$( "{0}.{1}" -f $psProviderName, $psSnapInName ).html">
        </object>

"@
                if ( $GroupByPSSnapIn )
                {    
                    $providerCategoryContents += $providerTopicItem
                }
                else
                {
                    # save the topics so they can be sorted properly and added to the HHC later
                    $providerCategoryHash"{0}.{1}" -f $psProviderName, $psSnapInName ] = $providerTopicItem
                }
            }
        }
    
        if ( $GroupByPSSnapIn -and $providerCategoryContents -ne "" )
        {
            # add a category in the HHC for this PSSnapIn and its Cmdlets
            $providerCategoryHtml += @"
        <li><object type="text/sitemap">
          <param name="Name" value="$psSnapInName">
        </object>
        <ul>
          $( $providerCategoryContents.Trim() )
        </ul>

"@
        }
    }
}

# sort the PSProviders so they are added to the HHC in a logical order
if ( !$GroupByPSSnapIn )
{
    $providerCategoryHash.Keys | Sort-Object | Foreach-Object {
        $providerCategoryHtml += $providerCategoryHash$_ ]
    }
}

# add the PSProvider category to the HHC
$hhcContentsHtml += @"
      <li><object type="text/sitemap">
        <param name="Name" value="Provider Help">
      </object>
      <ul>
        $( $providerCategoryHtml.Trim() )
      </ul>

"@

# get the about topics
$about_TopicPaths = @()

$helpPath = ""

if ( Resolve-Path "$pshome\about_*.txt" )
{
    $helpPath = "$pshome"
}
elseif ( Resolve-Path "$pshome\$( $Host.CurrentUICulture.Name )\about_*.txt" )
{
    $helpPath = "$pshome\$( $Host.CurrentUICulture.Name )"
}

if ( Test-Path $helpPath )
{
    $about_TopicPaths += Get-ChildItem "$helpPath\about_*.txt"
}

# we SilentlyContinue with Get-ChildItem errors because the ModuleName
# for the built-in PSSnapins doesn't resolve to anything, since the assemblies
# are only in the GAC.
$about_TopicPaths += Get-PSSnapin | Foreach-Object { 
    ( Get-ChildItem $_.ModuleName -ErrorAction "SilentlyContinue" ).DirectoryName 
| Foreach-Object { 
    Get-ChildItem "$_\about_*.txt" 
}

if ( $about_TopicPaths.Count -gt 0 )
{
    $aboutCategoryHtml = ""
    
    $about_TopicPaths | Sort-Object -Unique -Property @{ Expression = { $_.Name.ToUpper() } }| Foreach-Object {
        # pull the topic name out of the file name
        $name = ( $_.Name -replace "(.xml)?.help.txt", "`$1" )
    
        # add the path of the topic to the help hashtable
        $helpHash$name ] = $_.FullName
        $helpTopicNameArray += $name
    
        $topicName = Capitalize-Words ( $name -replace "(about)?_", " " ).Trim()
    
        # add a category in the HHC for this about topic
        $aboutCategoryHtml += @"
        <li><object type="text/sitemap">
          <param name="Name" value="$topicName">
          <param name="Local" value="Topics\$name.html">
        </object>

"@
    }

    # add the About Topics category to the HHC
    $hhcContentsHtml += @"
      <li><object type="text/sitemap">
        <param name="Name" value="About Topics">
      </object>
      <ul>
        $( $aboutCategoryHtml.Trim() )
      </ul>

"@
}

# write the contents file
Out-File -FilePath "$outDirectory\powershell.hhc" -Encoding Ascii -Input @"
<!doctype html public "-//ietf//dtd html//en">
<html>
  <head>
    <meta name="Generator" content="Microsoft&reg; HTML Help Workshop 4.1">
    <!-- Sitemap 1.0 -->
  </head>
  <body>
    <object type="text/site properties">
      <param name="Window Styles" value="0x800025">
    </object>
    <ul>
      <li><object type="text/sitemap">
        <param name="Name" value="PowerShell Help">
        <param name="Local" value="Topics\default.html">
      </object>
      $( $hhcContentsHtml.Trim() )
    </ul>
  </body>
</html>
"@

$helpHash.Keys | Sort-Object | Foreach-Object {
    switch -regex ( $_ )
    {
        # about topic
        "about_"
        {
            "Creating help for the $_ about topic..."
            Write-AboutTopic $_ $helpHash$_ ]
        }

        # Verb-Noun: Cmdlet
        "\w+-\w+"
        {
            "Creating help for the $( $_ -replace '(^\w+-\w+).*', '$1' ) Cmdlet..."
            Write-CmdletTopic $_ $helpHash$_ ]
        }
        
        # PSProvider
        default
        {
            "Creating help for the $( $_ -replace '(^\w+).*', '$1' ) PSProvider..."
            Write-ProviderTopic $_ $helpHash$_ ]
        }
    }
}

Write-DefaultPage
Write-Css
Write-Hhp

if ( Test-Path "C:\Program Files\HTML Help Workshop\hhc.exe" )
{
    # compile the help
    "`nCompiling the help manual...`n"
    Push-Location
    Set-Location $outDirectory
    & "C:\Program Files\HTML Help Workshop\hhc.exe" powershell.hhp
    Pop-Location
    
    # open the help file
    & "$outDirectory\PowerShell.chm"
}
else
{
    Write-Host -ForegroundColor Red @"

HTML Help Workshop is not installed, or it was not installed in its default
location of "C:\Program Files\HTML Help Workshop".

HTML Help Workshop is required to compile the help manual.  It can be downloaded
free of charge from Microsoft:

http://www.microsoft.com/downloads/details.aspx?familyid=00535334-c8a6-452f-9aa0-d597d16580cc&displaylang=en

If you do not want to install HTML Help Workshop on this machine, all of the
files necessary to compile the manual have been created here:

$( Resolve-Path $outDirectory ) 

Copy these files to a machine with HTML Help Workshop, and you can compile the
manual there, with the following command:

<HTML Help Workshop location>\hhc.exe powershell.hhp

"@
}
The XML help files are fairly consistent, but once in a while something is formatted a little differently. I tried to account for as much of this sort of thing as possible, but I'm sure I missed something. If you find something wrong with this script, please leave a comment here.

I hope this ends up being useful to someone else.