Running XSL Transforms Whenever You Change Your XSL File

I don't often have to write XSL transforms these days; it just doesn't come up as often as it did 10-15 years ago. On the rare occasion I do have to tinker with XSLT, I'd like instant feedback. I want to see the results of my changes right away, without having to manually run the transform (either from the command line or a menu item).

There are tools which can do this, but I don't do it often enough to justify paying for another software license. So I've cobbled together a couple of PowerShell scripts and a Chrome extension which give me instant feedback. Even if you don't have my specific problem (converting an XML report into a nice HTML version), you might find at least one part of this low-rent XSL studio useful.

I do my XSLT editing in Notepad++, but any editor will do for this setup.

The first thing we need is a way to run the XSL transform. Luckily, that's dead simple to do in PowerShell because we have access to the .NET System.Xml namespace. Loading an XSL file from disk, compiling the transform, running it against an XML document, and sending the output to another file can be done in a few lines of code. Here's the entire xsl.ps1 script:

[CmdletBinding()]
Param(
  [Parameter(Mandatory=$True)]
  [string]$xmlFile,
  [Parameter(Mandatory=$True)]
  [string]$xsltFile,
  [Parameter(Mandatory=$False)]
  [string]$outputFile = "output.xml"
)

$xmlFile = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine((Get-Location), $xmlFile))
$xsltFile = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine((Get-Location), $xsltFile))
$outputFile = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine((Get-Location), $outputFile))

# Create the transform
$xslt = New-Object System.Xml.Xsl.XslCompiledTransform( $false )

# Create a couple of other argument objects we'll need
$arglist = new-object System.Xml.Xsl.XsltArgumentList
$xsltSettings = New-Object System.Xml.Xsl.XsltSettings($false,$true)

# Load the XSL file
$xslt.Load($xsltFile, $xsltSettings, (New-Object System.Xml.XmlUrlResolver))

# Open a file for output
$outFile = New-Object System.IO.FileStream($outputFile, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)

# Run the transform
$xslt.Transform($xmlFile, $arglist, $outFile)

# Close the output file
$outFile.Close()

I should add a disclaimer that this script supports my specific scenario; a more idiomatically PowerShell-ish script would probably allow you to pipe the XML content in and return the result instead of just writing it to a file. But I'm lazy and this gets the job done. To use it, you simply specify the source XML file, the XSLT file, and optionally the path to the output file:

xsl .\example.xml .\example.xslt .\example.html

Next, we need a way to watch the XSL file so we can re-run the previous script every time it changes. The .NET Framework provides the handy FileSystemWatcher class for exactly this kind of thing. You can point it at a path and it will raise events when that path changes. And PowerShell has a command called Register-ObjectEvent which allow us to subscribe to framework object events and run PowerShell script blocks when they occur. Here's my watch.ps1 script which watches a target path and runs the specified script block whenever the file at that path changes:

[CmdletBinding()]
Param(
  [Parameter(Mandatory=$True)]
  [string]$target,
  [Parameter(Mandatory=$True)]
  [string]$action
)

if(-not [System.IO.Path]::IsPathRooted($target)) {
    $target = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine((Get-Location), $target))
}

if($action -eq "stop") { 
    Write-Host "Stopping watch on $target"
    Unregister-Event FileChanged 
    Write-Host "Stopped"
} else {
    $fsw = New-Object IO.FileSystemWatcher ([System.IO.Path]::GetDirectoryName($target)), ([System.IO.Path]::GetFileName($target)) -Property @{IncludeSubdirectories = $false; NotifyFilter = [IO.NotifyFilters]'FileName, LastWrite'} 

    Register-ObjectEvent $fsw Changed -SourceIdentifier FileChanged -MessageData $action -Action { 
        try {
            iex ($Event.MessageData)
        } 
        catch [Exception] {
            write-host $_.Exception.ToString();
        }
    } 

    $fsw.EnableRaisingEvents = $True 
}

This is pretty straightforward. The script creates a FileSystemWatcher and then registers for the Changed event; when that event occurs, it invokes the script block specified by the -Action parameter. Note that we're not directly passing our $action parameter to Register-ObjectEvent; rather, we're using Invoke-Expression to call it and wrapping it in a try/catch block. This way, we can output any errors we get from the $action and still keep responding to later events. This is important for the XSLT process, because I make a lot of mistakes when trying to get XSL transforms working correctly. If I make a mistake in my XSL document, the exception information will pop up in my terminal window as soon as I hit "Save".

Firing up the watch script with the XSL transform is simple:

watch.ps1 .\example.xslt {xsl .\example.xml .\example.xslt example.html}

The watcher will continue to run in the background until we manually stop it:

watch.ps1 .\example.xslt stop

The final piece in my bootleg XSL studio is the LivePage extension for Chrome. It can watch an HTML document and automatically reload it whenever it changes. Just point it at the output HTML file (and make sure to check "Allow access to file URLs" for LivePage in the Chrome Extensions settings), and as you make your XSL changes you'll see the result in the browser.

Just a side note - it is technically possible to do most of this in Chrome without the PowerShell scripts; you just have to link your XML document with your stylesheet and run Chrome with the --allow-file-access-from-files switch. But it's kind of a pain to have to add the stylesheet reference to your XML document and manually refresh Chrome all the time. So I prefer my (admittedly) convoluted setup. Your mileage my vary.

Getting the forecast in PowerShell

Sometimes I'm working and I need to know what the weather is so I know whether to grab a sweatshirt or jacket (especially this time of year). I realize there are literally hundreds of ways to get this information (web sites, my phone, sticking my head out the window, etc.), but sometimes I just want the quickest possible way to get the highs and lows. I've pretty much always got a console window open - how hard is it to get the weather from the command line?

Turns out, it's pretty easy.

Many months ago I put together a short PowerShell one-liner to get the current temperature. The National Weather Service provides current observations at their weather stations via simple XML. Because PowerShell is so ridiculously good about handling XML, getting the current temperature at my nearest weather station is as simple as

([xml](Invoke-WebRequest -URI http://w1.weather.gov/xml/current_obs/KBDU.xml).Content).current_observation.temperature_string

which just writes out

73.0 F (23.0 C)

to the console. Invoke-WebRequest does what it says on the tin - retrieves the XML file from the web. Casting that file's content to [xml] makes PowerShell handle all the parsing into a nice object; from there you can just dig down to the property you care about. (I had figured this out so I could use it as an example for a plug-in I wrote for a chat robot for HipChat.)

The other day I wanted to improve on this a bit - the current temperature isn't that useful for deciding what to wear. I needed highs and lows. Luckily, it turns out that the National Digital Forecast Database has a web service which provides this information. Less luckily, it's a SOAP-based web service. For a moment, I thought this might really suck (last time I dealt with SOAP was over ten years ago, and even with the convenience of Visual Studio generating most of the code it was pretty painful).

It all turned out to be pretty easy, though.

For starters, getting the SOAP request to the server via PowerShell ended up being trivial.

$uri = "http://graphical.weather.gov/xml/SOAP_server/ndfdXMLserver.php"
$body = Get-Content .\ndfd.soap
[xml]$result = Invoke-WebRequest $uri -Method post -ContentType "text/xml" -Body $body

All you have to do is put the SOAP request in a variable and use Invoke-WebRequest to POST it. Once I found out this is all it took, I figured the hard part was going to be building the request. Turns out, that's not too hard, either, because the NWS has helpfully provided sample SOAP payloads for all of their services. I was interested in calling the NDFDgenByDay() method; here's the request body sample.

So all I needed to do was put in my latitude, longitude, and the date. I cheated by writing a naive template for the request to my PowerShell folder (that's the ndfd.soap in the script above); it's basically the sample request with the latitude, longitude, and date replaced with [lat], [long], and [date]. Building the request is simply a matter of reading the file from disk and replacing the slugs with the correct values:

$lat = "40.019444"
$lon = "-105.292778"
$start =  Get-Date -format "yyyy-MM-dd"
$body = Get-Content .\ndfd.soap
$body = $body.Replace("[lat]", $lat).Replace("[lon]", $lon).Replace("[start]", $start)

After that, it's just a matter of drilling down to the properties you want in the results. The return formats from the various NWS web services are pretty complex; figuring out exactly where the data you want is located ends up being the most time-consuming part. But there's a wealth of data in the various services - you can retrieve data on precipitation, snow accumulation, weather warnings and hazards, wind, and just about anything else you can think of.

Here's my final script for getting the next 6-7 days of highs, lows, and general forecast:

$uri = "http://graphical.weather.gov/xml/SOAP_server/ndfdXMLserver.php"
$lat = "40.019444"
$lon = "-105.292778"
$start =  Get-Date -format "yyyy-MM-dd"

$body = Get-Content .\ndfd.soap
$body = $body.Replace("[lat]", $lat).Replace("[lon]", $lon).Replace("[start]", $start)

[xml]$envelope = Invoke-WebRequest $uri -Method post -ContentType "text/xml" -Body $body
[xml]$weather = $envelope.Envelope.Body.NDFDgenByDayResponse.dwmlByDayOut.'#text'

$params = $weather.dwml.data.parameters

$days = ($params.temperature | select name, value) + ($params.weather | select name, @{Name="value";Expression={$_.'weather-conditions'}})

Write-Host
"Next several days:"

for($i = 0; $i -lt 7; $i++){
    if(-not $days[0].value[$i].nil) {
        ("High of " + $days[0].value[$i] + ", low of " + $days[1].value[$i] + ", " + $days[2].value[$i].'weather-summary')
    }
}

and here's what the output looks like:

Next several days:
High of 72,  low of 49, Sunny
High of 71,  low of 48, Mostly Sunny
High of 58,  low of 43, Chance Rain Showers
High of 54,  low of 39, Chance Rain Showers
High of 63,  low of 43, Partly Sunny
High of 61,  low of 40, Partly Sunny

Update:

There's a much-improved version of this script in my follow-up post.