XMLLab.psm1



function Compare-Xml
{
<#
.SYNOPSIS
Compares two XML documents and returns the differences.
 
.INPUTS
System.Xml.XmlDocument to compare to the reference XML.
 
.OUTPUTS
System.Xml.XmlDocument containing XSLT that can be applied to the reference XML to
transform it to the difference XML. It contains templates for changed nodes.
 
.FUNCTIONALITY
XML
 
.LINK
Resolve-XPath
 
.EXAMPLE
Compare-Xml '<a b="z"/>' '<a b="y"/>' |Format-Xml
 
<xsl:transform version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:template match="@*|node()">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()" />
    </xsl:copy>
  </xsl:template>
  <xsl:template match="/a/@b">
    <xsl:attribute name="b"><![CDATA[y]]></xsl:attribute>
  </xsl:template>
</xsl:transform>
 
.EXAMPLE
Compare-Xml '<a b="z"/>' '<a c="y"/>' |Format-Xml
 
<xsl:transform version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:template match="@*|node()">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()" />
    </xsl:copy>
  </xsl:template>
  <xsl:template match="/a/@b" />
  <xsl:template match="/a">
    <xsl:copy>
      <xsl:apply-templates select="@*" />
      <xsl:attribute name="c"><![CDATA[y]]></xsl:attribute>
    </xsl:copy>
  </xsl:template>
</xsl:transform>
 
.EXAMPLE
Compare-Xml '<a><b/><c/><!-- d --></a>' '<a><c/><b/></a>' |Format-Xml
 
<xsl:transform version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:template match="@*|node()">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()" />
    </xsl:copy>
  </xsl:template>
  <xsl:template match="/a">
    <xsl:copy>
      <xsl:apply-templates select="c" />
      <b />
    </xsl:copy>
  </xsl:template>
</xsl:transform>
 
.EXAMPLE
Compare-Xml '<a/>' '<a><!-- annotation --><new/><?node details?></a>' |Format-Xml
 
<xsl:transform version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:template match="@*|node()">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()" />
    </xsl:copy>
  </xsl:template>
  <xsl:template match="/a">
    <xsl:copy>
      <xsl:comment><![CDATA[ annotation ]]></xsl:comment>
      <new />
      <xsl:processing-instruction name="node"><![CDATA[details]]></xsl:processing-instruction>
    </xsl:copy>
  </xsl:template>
</xsl:transform>
#>


[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter','',Justification='These params are used.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns','',Justification='These plurals are fine.')]
[CmdletBinding()][OutputType([xml])] Param(
# The original XML document to be compared.
[Parameter(Position=0,Mandatory=$true)][xml] $ReferenceXml,
# An XML document to compare to.
[Parameter(Position=1,Mandatory=$true)][xml] $DifferenceXml
)

function Test-XmlNodesMatch
{
    Param(
    [Parameter(Position=0)][Xml.XmlNode] $ReferenceNode,
    [Parameter(Position=1)][Xml.XmlNode] $DifferenceNode
    )
    if($null -eq $ReferenceNode) {return}
    elseif($null -eq $DifferenceNode) {return}
    elseif($ReferenceNode.NodeType -ne $DifferenceNode.NodeType) {return}
    elseif($ReferenceNode.NamespaceURI -cne $DifferenceNode.NamespaceURI) {return}
    elseif($ReferenceNode.LocalName -cne $DifferenceNode.LocalName) {return}
    else {return $true}
}

function Test-XmlAttributesEqual
{
    Param(
    [Parameter(Position=0)][Xml.XmlElement] $ReferenceElement,
    [Parameter(Position=1)][Xml.XmlElement] $DifferenceElement
    )
    if($ReferenceElement.Attributes.Count -ne $DifferenceElement.Attributes.Count) {return $false}
    foreach(${@} in $ReferenceElement.Attributes)
    {
        if(${@}.NamespaceURI)
        {
            if(!$DifferenceElement.HasAttribute(${@}.LocalName,${@}.NamespaceURI) -or
                $DifferenceElement.GetAttribute(${@}.LocalName,${@}.NamespaceURI) -ne ${@}.Value) {return $false}
        }
        else
        {
            if(!$DifferenceElement.HasAttribute(${@}.LocalName) -or
                $DifferenceElement.GetAttribute(${@}.LocalName) -ne ${@}.Value) {return $false}
        }
    }
    return $true
}

function Test-XmlNodesEqual
{
    Param(
    [Parameter(Position=0)][Xml.XmlNode] $ReferenceNode,
    [Parameter(Position=1)][Xml.XmlNode] $DifferenceNode
    )
    if($ReferenceNode.OuterXml -ceq $DifferenceNode.OuterXml) {return $true}
    elseif(!(Test-XmlNodesMatch $ReferenceNode $DifferenceNode)) {return $false}
    elseif($ReferenceNode.NodeType -eq 'Element' -and
        !(Test-XmlAttributesEqual $ReferenceNode $DifferenceNode)) {return $false}
    elseif($ReferenceNode.ChildNodes.Count -ne $DifferenceNode.ChildNodes.Count) {return $false}
    else
    {
        for($i = 0; $i -lt $ReferenceNode.ChildNodes.Count; $i++)
        {
            if(!(Test-XmlNodesEqual $ReferenceNode.ChildNodes[$i] $DifferenceNode.ChildNodes[$i])) {return $false}
        }
        return $ReferenceNode.Value -ceq $DifferenceNode.Value
    }
}

function Format-XPathMatch
{
    Param([Parameter(Position=0,Mandatory=$true)][Xml.XmlNode] $XmlNode)
    $xpath = Resolve-XPath $XmlNode
    "match='$($xpath.XPath)' $($xpath.Namespace.GetEnumerator() |ForEach-Object {"xmlns:$($_.Name)='$($_.Value)'"})"
}

function ConvertTo-XmlAttributeTemplate
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][Xml.XmlAttribute] $ReferenceAttribute,
    [Parameter(Position=1,Mandatory=$true)][Xml.XmlAttribute] $DifferenceAttribute
    )
    return [xml]@"
<xsl:template $(Format-XPathMatch $ReferenceAttribute) xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:attribute name="$($DifferenceAttribute.Name)"><![CDATA[$($DifferenceAttribute.Value)]]></xsl:attribute>
</xsl:template>
"@

}

function ConvertTo-XmlCDataTemplate
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][Xml.XmlCDataSection] $ReferenceCData,
    [Parameter(Position=1,Mandatory=$true)][Xml.XmlCDataSection] $DifferenceCData
    )
    return [xml]@"
<xsl:template $(Format-XPathMatch $ReferenceCData) xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:text><![CDATA[$($DifferenceCData.Value)]]></xsl:text>
</xsl:template>
"@

}

function ConvertTo-XmlCommentTemplate
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][Xml.XmlComment] $ReferenceComment,
    [Parameter(Position=1,Mandatory=$true)][Xml.XmlComment] $DifferenceComment
    )
    return [xml]@"
<xsl:template $(Format-XPathMatch $ReferenceComment) xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:comment><![CDATA[$($DifferenceComment.Value)]]></xsl:comment>
</xsl:template>
"@

}

function ConvertTo-XmlProcessingInstructionTemplate
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][Xml.XmlProcessingInstruction] $ReferenceProcessingInstruction,
    [Parameter(Position=1,Mandatory=$true)][Xml.XmlProcessingInstruction] $DifferenceProcessingInstruction
    )
    return [xml]@"
<xsl:template $(Format-XPathMatch $ReferenceProcessingInstruction) xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:processing-instruction name="$($DifferenceProcessingInstruction.Name)">
        <![CDATA[$($DifferenceProcessingInstruction.Value)]]>
    </xsl:processing-instruction>
</xsl:template>
"@

}

function ConvertTo-XmlSignificantWhitespaceTemplate
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][Xml.XmlSignificantWhitespace] $ReferenceSignificantWhitespace,
    [Parameter(Position=1,Mandatory=$true)][Xml.XmlSignificantWhitespace] $DifferenceSignificantWhitespace
    )
    return [xml]@"
<xsl:template $(Format-XPathMatch $ReferenceSignificantWhitespace) xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:text><![CDATA[$($DifferenceSignificantWhitespace.Value)]]></xsl:text>
</xsl:template>
"@

}

function ConvertTo-XmlTextTemplate
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][Xml.XmlText] $ReferenceText,
    [Parameter(Position=1,Mandatory=$true)][Xml.XmlText] $DifferenceText
    )
    return [xml]@"
<xsl:template $(Format-XPathMatch $ReferenceText) xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:text><![CDATA[$($DifferenceText.Value)]]></xsl:text>
</xsl:template>
"@

}

function ConvertTo-XmlWhitespaceTemplate
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][Xml.XmlWhitespace] $ReferenceWhitespace,
    [Parameter(Position=1,Mandatory=$true)][Xml.XmlWhitespace] $DifferenceWhitespace
    )
    if($ReferenceWhitespace.Value -ceq $DifferenceWhitespace.Value) {return}
    return [xml]@"
<xsl:template $(Format-XPathMatch $ReferenceWhitespace) xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:text>$($DifferenceWhitespace.Value)"></xsl:text>
</xsl:template>
"@

}

function Measure-XmlNodePosition([Parameter(Position=0,Mandatory=$true)][Xml.XmlNode]$Node)
{
    if(!($Node.PreviousSibling -or $Node.NextSibling)) {return}
    for($i,$n = 0,$Node; $n; $n = $n.PreviousSibling) {if(Test-XmlNodesMatch $n $Node) {$i++}}
    if($i -gt 1) {return "[$i]"}
    for($i,$n = 0,$Node.NextSibling; $n; $n = $n.NextSibling) {if(Test-XmlNodesMatch $n $Node) {$i++; break}}
    if($i -gt 0) {return '[1]'}
    else {return}
}

filter Format-ApplyTemplates
{
    [CmdletBinding()][OutputType([string])] Param(
    [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)][Xml.XmlNodeType] $NodeType,
    [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)][ValidateNotNullOrEmpty()][string] $Name,
    [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)][string] $LocalName,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Prefix,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $NamespaceURI,
    [Parameter(ValueFromPipeline=$true)][Xml.XmlNode] $Node
    )
    $fullname = if($NamespaceURI -and !$Prefix) {$Prefix = 'ns'; "ns:$LocalName"} else {$Name}
    $xpath = switch($NodeType)
    {
        Attribute {"@$fullname"}
        CDATA {"text()$(Measure-XmlNodePosition $Node)"}
        Comment {"comment()$(Measure-XmlNodePosition $Node)"}
        Document {'/'}
        Element {"$fullname$(Measure-XmlNodePosition $Node)"}
        ProcessingInstruction {"processing-instruction('$name')$(Measure-XmlNodePosition $Node)"}
        SignificantWhitespace {"text()$(Measure-XmlNodePosition $Node)"}
        Text {"text()$(Measure-XmlNodePosition $Node)"}
        Whitespace {"text()$(Measure-XmlNodePosition $Node)"}
        default {return}
    }
    if(!$NamespaceURI) {return "<xsl:apply-templates select='$xpath'/>"}
    else {return "<xsl:apply-templates select='$xpath' xmlns:$Prefix='$NamespaceURI'/>"}
}

function ConvertTo-XmlNodeLiteral
{
    [CmdletBinding()][OutputType([string])] Param([Parameter(Position=0,Mandatory=$true)][Xml.XmlNode] $Node)
    switch($Node.NodeType)
    {
        CDATA {return "<xsl:text><![CDATA[$($Node.Value)]]></xsl:text>"}
        Comment {return "<xsl:comment><![CDATA[$($Node.Value)]]></xsl:comment>"}
        Element {return $Node.OuterXml}
        ProcessingInstruction {return ("<{0} name='$($Node.Name)'><![CDATA[$($Node.Value)]]></{0}>" -f
            'xsl:processing-instruction')}
        SignificantWhitespace {return "<xsl:text><![CDATA[$($Node.Value)]]></xsl:text>"}
        Text {return "<xsl:text><![CDATA[$($Node.Value)]]></xsl:text>"}
        Whitespace {return "<xsl:text><![CDATA[$($Node.Value)]]></xsl:text>"}
        default {return}
    }
}

function Merge-XmlNodes
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][AllowEmptyCollection()][Xml.XmlNode[]] $ReferenceNodes,
    [Parameter(Position=1,Mandatory=$true)][AllowEmptyCollection()][Xml.XmlNode[]] $DifferenceNodes
    )
    for($d,$list = 0,@(); $d -lt $DifferenceNodes.Length; $d++)
    {
        $diff = $DifferenceNodes[$d]
        [int[]] $matches =
            if($ReferenceNodes.Length -le 0) {@()}
            else {0..($ReferenceNodes.Length-1) |Where-Object {Test-XmlNodesMatch $ReferenceNodes[$_] $diff}}
        [int] $r =
            if(!$matches -or $matches.Length -eq 0) {-1}
            elseif($matches.Length -eq 1) {$matches[0]}
            else
            {
                $equals = $matches |Where-Object {Test-XmlNodesEqual $ReferenceNodes[$_] $diff} |Select-Object -First 1
                if($equals.Length -eq 0) {$matches[0]}
                else {$equals[0]}
            }
        $list += [pscustomobject]@{
            ReferenceNode   = if($r -eq -1) {$null} else {$ReferenceNodes[$r]}
            ReferenceIndex  = $r
            DifferenceNode  = $DifferenceNodes[$d]
            DifferenceIndex = $d
            Template        = if($r -eq -1) {$null} else {ConvertTo-XmlNodeTemplates $ReferenceNodes[$r] $diff}
        }
        if($r -ne -1) {$ReferenceNodes[$r] = [xml]'<null/>'}
    }
    return ([pscustomobject]@{
        HasDifferentOrder = !!($list |Where-Object {$_.ReferenceIndex -ne $_.DifferenceIndex})
        ApplyTemplates    = ($list |
            ForEach-Object {
                if($_.ReferenceNode) {$_.ReferenceNode |Format-ApplyTemplates}
                else {ConvertTo-XmlNodeLiteral $_.DifferenceNode}
            }
        ) -join [environment]::NewLine
        Templates         = $list |Where-Object Template -ne $null |Select-Object -ExpandProperty Template
    })
}

filter Add-XmlAttribute
{
    [CmdletBinding()][OutputType([string])] Param(
    [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)][ValidateNotNullOrEmpty()][string] $Name,
    [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)][string] $LocalName,
    [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)][string] $Value,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Prefix,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $NamespaceURI
    )
    if($NamespaceURI)
    {
        if(!$Prefix) {$Prefix = 'ns'}
        return "<xsl:attribute name='${Prefix}:$LocalName' xmlns:$Prefix='$NamespaceURI'><![CDATA[$Value]]></xsl:attribute>"
    }
    else {return "<xsl:attribute name='$Name'><![CDATA[$Value]]></xsl:attribute>"}
}

function ConvertTo-XmlElementTemplates
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][Xml.XmlElement] $ReferenceElement,
    [Parameter(Position=1,Mandatory=$true)][Xml.XmlElement] $DifferenceElement
    )
    ${+} = @()
    foreach(${@} in $DifferenceElement.Attributes)
    {
        if(${@}.NamespaceURI)
        {
            if(!$ReferenceElement.HasAttribute(${@}.LocalName,${@}.NamespaceURI)) {${+} += ${@}}
            else {ConvertTo-XmlNodeTemplates $ReferenceElement.GetAttributeNode(${@}.LocalName,${@}.NamespaceURI) ${@}}
        }
        else
        {
            if(!$ReferenceElement.HasAttribute(${@}.Name)) {${+} += ${@}}
            else {ConvertTo-XmlNodeTemplates $ReferenceElement.GetAttributeNode(${@}.Name) ${@}}
        }
    }
    foreach(${@} in $ReferenceElement.Attributes)
    {
        if(!${@}.NamespaceURI) {if(!$DifferenceElement.HasAttribute(${@}.LocalName)) {ConvertTo-XmlNodeTemplates ${@} $null}}
        elseif(!$DifferenceElement.HasAttribute(${@}.LocalName,${@}.NamespaceURI)) {ConvertTo-XmlNodeTemplates ${@} $null}
    }
    $merge = Merge-XmlNodes $ReferenceElement.ChildNodes $DifferenceElement.ChildNodes
    $merge.Templates
    if(${+} -or $merge.HasDifferentOrder)
    {[xml]@"
<xsl:template $(Format-XPathMatch $ReferenceElement) xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:copy>
        $(if($ReferenceElement.HasAttributes) {'<xsl:apply-templates select="@*" />'})
        $(if(${+}) {${+} |Add-XmlAttribute})
        $($merge.ApplyTemplates)
    </xsl:copy>
</xsl:template>
"@
}
}

function ConvertTo-XmlDocumentTemplates
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][xml] $ReferenceDocument,
    [Parameter(Position=1,Mandatory=$true)][xml] $DifferenceDocument
    )
    $ns = 'xmlns:xsl="http://www.w3.org/1999/XSL/Transform"'
    $declaration =
        if($DifferenceDocument.FirstChild.NodeType -eq 'XmlDeclaration')
        {
            "omit-xml-declaration='yes'"
            $encoding = $DifferenceDocument.FirstChild.Encoding
            if($encoding) {"encoding='$encoding'"}
            $standalone = $DifferenceDocument.FirstChild.Standalone
            if($standalone) {"standalone='$standalone'"}
        }
    $doctype = $DifferenceDocument.ChildNodes |Where-Object NodeType -ceq 'DocumentType'
    if($doctype)
    {
        if($doctype.PublicId -like '-//W3C//DTD XHTML *')
        {[xml]@"
<xsl:output $declaration method="xhtml" doctype-public="$($doctype.PublicId)" doctype-system="$($doctype.SystemId)" $ns />
"@
}
        elseif($doctype.name -ceq 'html')
        {[xml]@"
<xsl:output $declaration method="html" doctype-public="$($doctype.PublicId)" doctype-system="$($doctype.SystemId)" $ns />
"@
}
        else
        {[xml]@"
<xsl:output $declaration method="xml" doctype-public="$($doctype.PublicId)" doctype-system="$($doctype.SystemId)" $ns />
"@
}
    }
    elseif($declaration)
    {[xml]@"
<xsl:output $declaration method="xml" $ns />
"@
}
[xml]@"
<xsl:template match="@*|node()" $ns><xsl:copy><xsl:apply-templates select="@*|node()" /></xsl:copy></xsl:template>
"@

    $reorder = $false
    if($ReferenceDocument.DocumentElement.PreviousSibling -or $DifferenceDocument.DocumentElement.PreviousSibling)
    {
        $refpre = foreach($node in $ReferenceDocument.ChildNodes)
        {
            if($node.NodeType -ceq 'Element') {break}
            elseif($node.NodeType -notin 'XmlDeclaration','DocumentType') {$node}
        }
        if($null -eq $refpre) {$refpre = @()}
        $diffpre = foreach($node in $DifferenceDocument.ChildNodes)
        {
            if($node.NodeType -ceq 'Element') {break}
            elseif($node.NodeType -notin 'XmlDeclaration','DocumentType') {$node}
        }
        if($null -eq $diffpre) {$diffpre = @()}
        $mergepre = Merge-XmlNodes $refpre $diffpre
        $reorder = $mergepre.HasDifferentOrder
        $mergepre.Templates
    }
    ConvertTo-XmlElementTemplates $ReferenceDocument.DocumentElement $DifferenceDocument.DocumentElement
    if($ReferenceDocument.DocumentElement.NextSibling -or $DifferenceDocument.DocumentElement.NextSibling)
    {
        $refpost = for($node = $ReferenceDocument.DocumentElement.NextSibling; $node; $node = $node.NextSibling) {$node}
        if($null -eq $refpost) {$refpost = @()}
        $diffpost = for($node = $DifferenceDocument.DocumentElement.NextSibling; $node; $node = $node.NextSibling) {$node}
        if($null -eq $diffpost) {$diffpost = @()}
        $mergepost = Merge-XmlNodes $refpost $diffpost
        if(!$reorder) {$reorder = $mergepost.HasDifferentOrder}
        $mergepost.Templates
    }
    if($reorder)
    {[xml]@"
<xsl:template match="/" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    $($mergepre.ApplyTemplates)
    <xsl:apply-templates select="*"/>
    $($mergepost.ApplyTemplates)
</xsl:template>
"@
}
}

function ConvertTo-XmlNodeTemplates
{
    Param(
    [Parameter(Position=0,Mandatory=$true)][Xml.XmlNode] $ReferenceNode,
    [Parameter(Position=1)][Xml.XmlNode] $DifferenceNode
    )
    if($null -eq $DifferenceNode) {return [xml]@"
<xsl:template $(Format-XPathMatch $ReferenceNode) xmlns:xsl="http://www.w3.org/1999/XSL/Transform"/>
"@
}
    if(Test-XmlNodesEqual $ReferenceNode $DifferenceNode) {return}
    switch($DifferenceNode.NodeType)
    {
        Attribute {ConvertTo-XmlAttributeTemplate $ReferenceNode $DifferenceNode}
        CDATA {ConvertTo-XmlCDataTemplate $ReferenceNode $DifferenceNode}
        Comment {ConvertTo-XmlCommentTemplate $ReferenceNode $DifferenceNode}
        Document {ConvertTo-XmlDocumentTemplates $ReferenceNode $DifferenceNode}
        Element {ConvertTo-XmlElementTemplates $ReferenceNode $DifferenceNode}
        ProcessingInstruction {ConvertTo-XmlProcessingInstructionTemplate $ReferenceNode $DifferenceNode}
        SignificantWhitespace {ConvertTo-XmlSignificantWhitespaceTemplate $ReferenceNode $DifferenceNode}
        Text {ConvertTo-XmlTextTemplate $ReferenceNode $DifferenceNode}
        Whitespace {ConvertTo-XmlWhitespaceTemplate $ReferenceNode $DifferenceNode}
        default {return}
    }
}

function Compare-Xml
{
    if($ReferenceXml.DocumentElement.NamespaceURI -cne $DifferenceXml.DocumentElement.NamespaceURI -or
        $ReferenceXml.DocumentElement.LocalName -cne $DifferenceXml.DocumentElement.LocalName)
    {
        [xml]$value = $DifferenceXml.Clone()
        # simplified transform: https://www.w3.org/TR/1999/REC-xslt-19991116#result-element-stylesheet
        [void]$value.DocumentElement.SetAttribute('version','http://www.w3.org/1999/XSL/Transform','1.0')
    }
    else
    {
        [xml]$value = '<xsl:transform version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"/>'
        foreach($template in ConvertTo-XmlNodeTemplates $ReferenceXml $DifferenceXml)
        {
            if($null -eq $template) {continue}
            $template.DocumentElement.RemoveAttribute('xmlns:xsl')
            [void]$value.DocumentElement.AppendChild($value.ImportNode([Xml.XmlNode]$template.DocumentElement,$true))
        }
    }
    return $value
}

Compare-Xml

}

function Convert-Xml
{
<#
.SYNOPSIS
Transform XML using an XSLT template.
 
.FUNCTIONALITY
XML
 
.EXAMPLE
Convert-Xml '<a xsl:version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"/>' '<z/>' |Format-Xml
 
<a />
 
.EXAMPLE
Convert-Xml -TransformFile xsd2html.xslt -Path schema.xsd -OutFile schema.html
 
Writes schema.html by applying the xsd2html.xslt transformation to schema.xsd.
#>


[CmdletBinding(SupportsShouldProcess=$true)]
[OutputType(ParameterSetName='Xml',[void])][OutputType(ParameterSetName='File',[void])] Param(
# An XML document containing an XSLT transform.
[Parameter(ParameterSetName='Xml',Position=0,Mandatory=$true)][xml] $TransformXslt,
# An XML document to transform.
[Parameter(ParameterSetName='Xml',Position=1,ValueFromPipeline=$true)][xml] $Xml,
# The XSLT file to use to transform the XML.
[Parameter(ParameterSetName='File',Mandatory=$true)][Alias('TemplateFile','XsltTemplateFile')][string] $TransformFile,
# The XML file to transform.
[Parameter(ParameterSetName='File',ValueFromPipelineByPropertyName=$true)][Alias('XmlFile','FullName')][string] $Path,
# The file to write the transformed XML to.
[Parameter()][string] $OutFile,
<#
When specified, indicates the XSLT is trusted, enabling the document()
function and embedded script blocks.
#>

[switch] $TrustedXslt
)
Begin
{
    if($PSCmdlet.ParameterSetName -eq 'File')
    {
        [xml] $TransformXslt = Resolve-Path $TransformFile |Get-Content -Raw
        [xml] $Xml = Resolve-Path $Path |Get-Content -Raw
    }
    [version] $xsltversion = Select-Xml '/*/@version' $TransformXslt -Namespace @{
            xsl='http://www.w3.org/1999/XSL/Transform'} |
            Select-Object -ExpandProperty Node |
            Select-Object -ExpandProperty Value
    if($xsltversion -gt '1.0')
    { throw "XSLT version $xsltversion is not supported by the CLR." }
    $xslt = New-Object Xml.Xsl.XslCompiledTransform
    try
    {
        $xslt.Load($TransformXslt,
            $(if($TrustedXslt) {[Xml.Xsl.XsltSettings]::TrustedXslt}else {[Xml.Xsl.XsltSettings]::Default}),
            (New-Object Xml.XmlUrlResolver -Property @{Credentials = [Net.CredentialCache]::DefaultCredentials}))
    }
    catch [Management.Automation.MethodInvocationException]
    {
        for($ex = $_.Exception.InnerException; $ex; $ex = $ex.InnerException) {Write-Error $ex.Message}
        throw
    }
}
Process
{
    if($PSCmdlet.ParameterSetName -eq 'File') {[xml] $Xml = Resolve-Path $Path |Get-Content -Raw}
    if(!$OutFile)
    {
        $ms = New-Object IO.MemoryStream
        $xw = [Xml.XmlWriter]::Create($ms,$xslt.OutputSettings)
        $xslt.Transform($Xml,$xw)
        $xw.Close() ; $xw.Dispose() ; $xw = $null
        [void]$ms.Seek(0,0)
        $result = New-Object xml
        $result.Load($ms)
        $ms.Close() ; $ms.Dispose() ; $ms = $null
        $result
    }
    else
    {
        $absOutFile = if((Split-Path $OutFile -IsAbsolute)) {$OutFile} else {Join-Path "$PWD" $OutFile}
        if((Test-Path $absOutFile) -and
            !$PSCmdlet.ShouldContinue("$(Get-Item $absOutFile |Select-Object FullName,LastWriteTime,Length)","Overwrite File?"))
        {Write-Warning "Skipping transform from $absPath to $OutFile"; return}
        $sw = New-Object IO.StreamWriter $absOutFile
        $xslt.Transform([Xml.XPath.IXPathNavigable]$Xml,(New-Object Xml.XmlTextWriter $sw))
        $sw.Close() ; $sw.Dispose() ; $sw = $null
    }
}

}

function ConvertFrom-EscapedXml
{
<#
.SYNOPSIS
Parse escaped XML into XML and serialize it.
 
.INPUTS
System.String, some escaped XML.
 
.FUNCTIONALITY
XML
 
.OUTPUTS
System.String, the XML parsed and serialized.
 
.EXAMPLE
ConvertFrom-EscapedXml '&lt;a href=&quot;http://example.org&quot;&gt;link&lt;/a&gt;'
 
<a href="http://example.org">link</a>
#>


[CmdletBinding()][OutputType([string])] Param(
# The escaped XML text.
[Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true)][string] $EscapedXml,
# Outputs the XML without indentation.
[Alias('NoIndent')][switch] $Compress
)
Process
{
    [xml] $xml = [Net.WebUtility]::HtmlDecode($EscapedXml)
    $sw = New-Object IO.StringWriter
    [Xml.XmlWriterSettings] $cfg = New-Object Xml.XmlWriterSettings -Property @{ Indent = !$Compress; OmitXmlDeclaration = $true }
    [Xml.XmlWriter] $xo = [Xml.XmlWriter]::Create($sw, $cfg)
    $xml.WriteTo($xo)
    $xo.Dispose()
    $out = $sw.ToString()
    $sw.Dispose()
    return $out
}

}

function ConvertFrom-XmlElement
{
<#
.SYNOPSIS
Converts named nodes of an element to properties of a PSObject, recursively.
 
.INPUTS
System.Xml.XmlElement to convert into a PSObject, or
System.Xml.XmlDocument to convert the document element into a PSObject, or
Microsoft.PowerShell.Commands.SelectXmlInfo output from Select-Xml to convert
each selected node into a PSObject.
 
.OUTPUTS
System.Management.Automation.PSCustomObject object created from selected XML.
 
.FUNCTIONALITY
XML
 
.LINK
Select-Xml
 
.EXAMPLE
Select-Xml /configuration/appSettings/add web.config |ConvertFrom-XmlElement
 
key value
--- -----
webPages:Enabled false
#>


[CmdletBinding()][OutputType([psobject])] Param(
# The XML document to convert to a PSObject.
[Parameter(ParameterSetName='Document',Position=0,Mandatory=$true,ValueFromPipeline=$true)][Xml.XmlDocument] $Document,
# The XML element to convert to a PSObject.
[Parameter(ParameterSetName='Element',Position=0,Mandatory=$true,ValueFromPipeline=$true)][Xml.XmlElement] $Element,
# Output from the Select-Xml cmdlet.
[Parameter(ParameterSetName='SelectXmlInfo',Position=0,Mandatory=$true,ValueFromPipeline=$true)]
[Microsoft.PowerShell.Commands.SelectXmlInfo] $SelectXmlInfo,
# Only include attributes, ignore other child nodes.
[Alias('Attributes','Atts')][switch] $OnlyAttributes,
# Replace any simple element that only contains one other element with its child.
[Alias('CollapseSimple')][switch] $SimpleSuccession
)
Process
{
    switch($PSCmdlet.ParameterSetName)
    {
        Document {$Document.DocumentElement |ConvertFrom-XmlElement -OnlyAttributes:$OnlyAttributes}
        SelectXmlInfo
        {
            @($SelectXmlInfo |ForEach-Object {[Xml.XmlElement]$_.Node} |
                ConvertFrom-XmlElement -OnlyAttributes:$OnlyAttributes)
        }
        Element
        {
            if($OnlyAttributes)
            {
                $properties = @{}
                $Element.Attributes |ForEach-Object {[void]$properties.Add($_.Name,$_.Value)}
                return [pscustomobject]$properties
            }
            elseif($Element.HasChildNodes -and !($Element.ChildNodes.NodeType |
                Select-Object -Unique |
                Where-Object {$_ -notin [Xml.XmlNodeType]::Text,[Xml.XmlNodeType]::CDATA}))
            {
                return $Element.InnerText
            }
            elseif($SimpleSuccession -and ($Element.Attributes.Count -eq 0) -and
                (($Element.SelectNodes('*') |Group-Object Name |Measure-Object).Count -eq 1))
            {
                return @($Element.SelectNodes('*') |ConvertFrom-XmlElement)
            }
            else
            {
                $properties = @{}
                $Element.Attributes |ForEach-Object {[void]$properties.Add($_.Name,$_.Value)}
                foreach($node in $Element.ChildNodes |Where-Object {$_.Name -and $_.Name -ne '#whitespace'})
                {
                    $subelements = $node.SelectNodes('*') |Group-Object Name
                    $value =
                        if($node.InnerText -and !$subelements)
                        {
                            $node.InnerText
                        }
                        elseif(($subelements |Measure-Object).Count -eq 1)
                        {
                            $subelement = $node.SelectSingleNode('*')
                            [pscustomobject]@{$subelement.Name=@($subelement |ConvertFrom-XmlElement)}
                        }
                        else
                        {
                            ConvertFrom-XmlElement $node
                        }
                    if(!$properties.Contains($node.Name))
                    { # new property
                        [void]$properties.Add($node.Name,$value)
                    }
                    else
                    { # property name collision!
                        if($properties[$node.Name] -isnot [Collections.Generic.List[object]])
                        { $properties[$node.Name] = ([Collections.Generic.List[object]]@($properties[$node.Name],$value)) }
                        else
                        { $properties[$node.Name].Add($value) }
                    }
                }
                return [pscustomobject]$properties
            }
        }
    }
}

}

function ConvertTo-XmlElements
{
<#
.SYNOPSIS
Serializes complex content into XML elements.
 
.INPUTS
System.Object (any object) to serialize.
 
.OUTPUTS
System.String for each XML-serialized value or property.
 
.FUNCTIONALITY
XML
 
.EXAMPLE
ConvertTo-XmlElements @{html=@{body=@{p='Some text.'}}} -SkipRoot
 
<html>
<body>
<p>Some text.</p>
</body>
</html>
 
.EXAMPLE
[pscustomobject]@{UserName='zaphodb';Computer='eddie'} |ConvertTo-XmlElements
 
<PSCustomObject>
<UserName>zaphodb</UserName>
<Computer>eddie</Computer>
</PSCustomObject>
 
.EXAMPLE
'{"item": {"name": "Test", "id": 1 } }' |ConvertFrom-Json |ConvertTo-XmlElements -SkipRoot
 
<item>
<name>Test</name>
<id>1</id>
</item>
#>


[CmdletBinding()][OutputType([string])] Param(
<#
A hash or XML element or other object to be serialized as XML elements.
 
Each hash value or object property value may itself be a hash or object or XML element.
#>

[Parameter(Position=0,ValueFromPipeline=$true)] $InputObject,
<#
Specifies how many levels of contained objects are included in the JSON representation.
The value can be any number from 0 to 100. The default value is 2.
ConvertTo-Json emits a warning if the number of levels in an input object exceeds this number.
#>

[ValidateRange('NonNegative')][int] $Depth = 3,
# Do not wrap the input in a root element.
[switch] $SkipRoot
)
Begin
{
    $Script:OFS = [Environment]::NewLine

    function ConvertTo-CompoundXmlElement
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipeline=$true)][string] $PropertyName,
        [switch] $IsFirst
        )
        Begin {if(!$IsFirst) {Write-Output ''}} # adds OFS
        Process
        {
            $name = $PropertyName -replace '\[\]','Array' -replace '\A\W+','_' -replace '\W+','-'
            Write-Output "<$name>$(ConvertTo-XmlElement $InputObject.$PropertyName -Depth ($Depth-1))</$name>"
        }
        End {if(!$IsFirst) {Write-Output ''}} # adds OFS
    }

    function ConvertTo-SimpleXmlElement
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipeline=$true)][string] $PropertyName,
        [switch] $IsFirst
        )
        Begin {if(!$IsFirst) {Write-Output ''}} # adds OFS
        Process
        {
            $name = $PropertyName -replace '\[\]','Array' -replace '\A\W+','_' -replace '\W+','-'
            Write-Output "<$name>$($InputObject.$PropertyName)</$name>"
        }
        End {if(!$IsFirst) {Write-Output ''}} # adds OFS
    }

    filter ConvertTo-XmlElement
    {
        [CmdletBinding()][OutputType([string])] Param(
        [Parameter(Position=0,ValueFromPipeline=$true)] $InputObject,
        [ValidateRange('NonNegative')][int] $Depth = 3,
        [switch] $IsFirst,
        [switch] $SkipRoot # not used here
        )
        if($null -eq $InputObject) {return '<null />'}
        elseif($InputObject -is [DBNull]) {return '<DBNull />'}
        elseif($InputObject -is [Array])
        { $InputObject |ConvertTo-XmlElement |ForEach-Object -Begin {''} -Process {"<Item>$_</Item>"} -End {''} }
        elseif([bool],[byte],[DateTimeOffset],[decimal],[double],[float],[guid],[int],[int16],[long],[sbyte],[timespan],[uint16],[uint32],[uint64] -contains $InputObject.GetType())
        { [Xml.XmlConvert]::ToString($InputObject) }
        elseif($InputObject -is [datetime])
        { [Xml.XmlConvert]::ToString($InputObject,'yyyy-MM-dd\THH:mm:ss') }
        elseif($InputObject -is [string] -or $InputObject -is [char])
        { [Security.SecurityElement]::Escape($InputObject) }
        elseif($InputObject -is [Hashtable] -or $InputObject -is [Collections.Specialized.OrderedDictionary])
        {
            if($Depth -gt 1) {$InputObject.Keys |ConvertTo-CompoundXmlElement -IsFirst:$IsFirst}
            else {$InputObject.Keys |ConvertTo-SimpleXmlElement -IsFirst:$IsFirst}
        }
        elseif($InputObject -is [PSObject])
        {
            if(!@($InputObject.PSObject.Properties)) {return}
            elseif($Depth -gt 1) {$InputObject.PSObject.Properties.Name |ConvertTo-CompoundXmlElement -IsFirst:$IsFirst}
            else {$InputObject.PSObject.Properties.Name |ConvertTo-SimpleXmlElement -IsFirst:$IsFirst}
        }
        elseif($InputObject -is [xml])
        { $InputObject.OuterXml }
        else
        {
            if(!@($InputObject.PSObject.Properties)) {return}
            elseif($Depth -gt 1)
            {
                $InputObject |
                    Get-Member -MemberType Properties |
                    Select-Object -ExpandProperty Name |
                    ConvertTo-CompoundXmlElement -IsFirst:$IsFirst
            }
            else
            {
                $InputObject |
                    Get-Member -MemberType Properties |
                    Select-Object -ExpandProperty Name |
                    ConvertTo-SimpleXmlElement -IsFirst:$IsFirst
            }
        }
    }
}
Process
{
    if($null -eq $InputObject) {return '<null />'}
    elseif($InputObject -is [DBNull]) {return '<DBNull />'}
    elseif($SkipRoot) {return "$(ConvertTo-XmlElement @PSBoundParameters -IsFirst)"}
    else
    {
        $root = $InputObject.GetType().Name -replace '\[\]','Array' -replace '\A\W+','_' -replace '\W+','-'
        return "<$root>$(ConvertTo-XmlElement @PSBoundParameters)</$root>"
    }
}

}

function Format-Xml
{
<#
.SYNOPSIS
Pretty-print XML.
 
.INPUTS
System.Xml.XmlDocument to serialize.
 
.OUTPUTS
System.String containing the serialized XML document with desired indents.
 
.FUNCTIONALITY
XML
 
.EXAMPLE
Get-PSProvider alias |ConvertTo-Xml |Format-Xml
 
<Objects>
  <Object Type="System.Management.Automation.ProviderInfo">
    <Property Name="ImplementingType" Type="System.RuntimeType">Microsoft.PowerShell.Commands.AliasProvider</Property>
    <Property Name="HelpFile" Type="System.String">System.Management.Automation.dll-Help.xml</Property>
    <Property Name="Name" Type="System.String">Alias</Property>
    <Property Name="PSSnapIn" Type="System.Management.Automation.PSSnapInInfo">Microsoft.PowerShell.Core</Property>
    <Property Name="ModuleName" Type="System.String">Microsoft.PowerShell.Core</Property>
    <Property Name="Module" Type="System.Management.Automation.PSModuleInfo" />
    <Property Name="Description" Type="System.String"></Property>
    <Property Name="Capabilities" Type="System.Management.Automation.Provider.ProviderCapabilities">ShouldProcess</Property>
    <Property Name="Home" Type="System.String"></Property>
    <Property Name="Drives" Type="System.Collections.ObjectModel.Collection`1[System.Management.Automation.PSDriveInfo]">
      <Property Type="System.Management.Automation.PSDriveInfo">Alias</Property>
    </Property>
  </Object>
</Objects>
 
.EXAMPLE
Get-PSProvider alias |ConvertTo-Xml |Format-Xml -NewLineOnAttributes
 
<Objects>
  <Object
    Type="System.Management.Automation.ProviderInfo">
    <Property
      Name="ImplementingType"
      Type="System.RuntimeType">Microsoft.PowerShell.Commands.AliasProvider</Property>
    <Property
      Name="HelpFile"
      Type="System.String">System.Management.Automation.dll-Help.xml</Property>
    <Property
      Name="Name"
      Type="System.String">Alias</Property>
    <Property
      Name="PSSnapIn"
      Type="System.Management.Automation.PSSnapInInfo">Microsoft.PowerShell.Core</Property>
    <Property
      Name="ModuleName"
      Type="System.String">Microsoft.PowerShell.Core</Property>
    <Property
      Name="Module"
      Type="System.Management.Automation.PSModuleInfo" />
    <Property
      Name="Description"
      Type="System.String"></Property>
    <Property
      Name="Capabilities"
      Type="System.Management.Automation.Provider.ProviderCapabilities">ShouldProcess</Property>
    <Property
      Name="Home"
      Type="System.String"></Property>
    <Property
      Name="Drives"
      Type="System.Collections.ObjectModel.Collection`1[System.Management.Automation.PSDriveInfo]">
      <Property
        Type="System.Management.Automation.PSDriveInfo">Alias</Property>
    </Property>
  </Object>
</Objects>
#>


[CmdletBinding()][OutputType([string])] Param(
# The XML string or document to format.
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][xml] $Xml,
# A whitespace indent character to use, space by default.
[ValidatePattern('\A\s\z',ErrorMessage='A whitespace character is required')]
[char] $IndentChar = ' ',
<#
The number of IndentChars to use per level of indent, 2 by default.
Set to zero for no indentation.
#>

[int] $Indentation = 2,
# Indicates attributes should be written on a new line.
[Alias('SplitAttributes','AttributesSeparated')][switch] $NewLineOnAttributes
)
Process
{
    [Xml.XmlWriterSettings] $cfg = New-Object Xml.XmlWriterSettings -Property @{
        Indent              = !!$Indentation
        IndentChars         = "$IndentChar" * $Indentation
        OmitXmlDeclaration  = $true
        NewLineOnAttributes = $NewLineOnAttributes
    }
    $sw = New-Object IO.StringWriter
    [Xml.XmlWriter] $xw = [Xml.XmlWriter]::Create($sw, $cfg)
    $Xml.WriteTo($xw)
    $xw.Dispose()
    $sw.ToString()
    $sw.Dispose()
}

}

function Get-XmlNamespaces
{
<#
.SYNOPSIS
Gets the namespaces from a document as a dictionary.
 
.OUTPUTS
System.Collections.Generic.Dictionary[System.String,System.String] containing namespace
prefixes as keys and namespace URIs as values.
 
.FUNCTIONALITY
XML
 
.LINK
https://stackoverflow.com/a/26786080/54323
 
.EXAMPLE
Select-Xml /xsl:transform .\dataref.xslt -Namespace (Get-XmlNamespaces .\dataref.xslt)
 
Node Path Pattern
---- ---- -------
transform C:\Users\brian\GitHub\scripts\dataref.xslt /xsl:transform
 
.EXAMPLE
Get-XmlNamespaces .\dataref.xslt
 
Key Value
--- -----
xml http://www.w3.org/XML/1998/namespace
xsl http://www.w3.org/1999/XSL/Transform
xs http://www.w3.org/2001/XMLSchema
x urn:guid:f203a737-cebb-419d-9fbe-a684f1f13591
wsdl http://schemas.xmlsoap.org/wsdl/
        http://www.w3.org/1999/xhtml
#>


[CmdletBinding()][OutputType([Collections.Generic.Dictionary[string,string]])] Param(
# The XML file.
[Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)][Alias('FullName')][string]$Path
)
Process
{
    $doc = [xml](Get-Content $Path -Raw)
    $nav = $doc.DocumentElement.CreateNavigator()
    [void]$nav.MoveToFollowing('Element')
    $nav.GetNamespacesInScope('All')
}

}

function Merge-XmlSelections
{
<#
.SYNOPSIS
Builds an object using the named XPath selections as properties.
 
.INPUTS
System.Xml.XmlNode of XML or System.String of XML file names to select property values from.
 
.OUTPUTS
System.Management.Automation.PSCustomObject object with the selected properties.
 
.FUNCTIONALITY
XML
 
.LINK
https://github.com/brianary/Detextive/
 
.EXAMPLE
Merge-XmlSelections @{Version='/*/@version';Format='/xsl:output/@method'} *.xsl* -Namespace @{xsl='http://www.w3.org/1999/XSL/Transform'}
 
Path Version Format
---- ------- ------
Z:\Scripts\dataref.xslt 2.0 html
Z:\Scripts\xhtml2fo.xsl 1.0 xml
#>


[CmdletBinding()][OutputType([psobject])] Param(
# Any dictionary or hashtable of property name to XPath to select a value with.
[Parameter(Position=0,Mandatory=$true)][Collections.IDictionary] $XPaths,
# The XML to select the property values from.
[Parameter(ParameterSetName='Xml',Position=1,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[Xml.XmlNode[]] $Xml,
# XML file(s) to select the property values from.
[Parameter(ParameterSetName='Path',Position=1,Mandatory=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('FullName')][string[]] $Path,
# XML namespaces to use in the XPath expressions.
[hashtable] $Namespace = $PSDefaultParameterValues['Select-Xml:Namespace']
)
Process
{
    $ns = if($Namespace -and $Namespace.Count) {@{Namespace=$Namespace}} else {@{}}
    if($PSCmdlet.ParameterSetName -eq 'Xml')
    {
        foreach($x in $Xml)
        {
            $value = [ordered]@{Path='InputStream';Xml=$x.OwnerDocument}
            foreach($prop in $XPaths.GetEnumerator())
            {
                $value.Add($prop.Key,($x |Select-Xml $prop.Value @ns |
                    Select-Object -ExpandProperty Node |
                    Select-Object -ExpandProperty Value))
            }
            [pscustomobject]$value
        }
    }
    else
    {
        foreach($f in $Path |Resolve-Path)
        {
            $value = [ordered]@{Path=$f}
            foreach($prop in $XPaths.GetEnumerator())
            {
                $value.Add($prop.Key,(Select-Xml $prop.Value -Path $f @ns |
                    Select-Object -ExpandProperty Node |
                    Select-Object -ExpandProperty Value))
            }
            [pscustomobject]$value
        }
    }
}

}

function New-NamespaceManager
{
<#
.SYNOPSIS
Creates an object to lookup XML namespace prefixes.
 
.OUTPUTS
System.Xml.XmlNamespaceManager containing the given namespaces.
 
.FUNCTIONALITY
XML
 
.LINK
https://docs.microsoft.com/dotnet/api/system.xml.xmlnamespacemanager
 
.EXAMPLE
$n = New-NamespaceManager; (Select-Xml //xhtml:td dataref.xslt).Node.SelectSingleNode('xhtml:var',$n).OuterXml
 
<var xmlns="http://www.w3.org/1999/xhtml">ANY</var>
<var xmlns="http://www.w3.org/1999/xhtml">ANY</var>
#>


[CmdletBinding()][OutputType([Xml.XmlNamespaceManager])] Param(
<#
A dictionary of prefixes and their namespace URLs.
If a default Namespace value for Select-Xml exists, this will use it.
#>

[ValidateNotNullOrEmpty()][Collections.IDictionary] $Namespaces = $PSDefaultParameterValues['Select-Xml:Namespace']
)
$value = New-Object Xml.XmlNamespaceManager (New-Object Xml.NameTable)
foreach($ns in $Namespaces.GetEnumerator())
{
    $value.AddNamespace($ns.Name,$ns.Value)
}
return,$value

}

function Resolve-XmlSchemaLocation
{
<#
.SYNOPSIS
Gets the namespaces and their URIs and URLs from a document.
 
.INPUTS
System.Xml.XmlDocument or System.String containing the path to an XML file.
 
.OUTPUTS
System.Management.Automation.PSCustomObject for each namespace, with Path,
Node, Alias, Urn, and Url properties.
 
.FUNCTIONALITY
XML
 
.LINK
https://www.w3.org/TR/xmlschema-1/#schema-loc
 
.LINK
https://stackoverflow.com/a/26786080/54323
 
.EXAMPLE
Resolve-XmlSchemaLocation test.xml
 
Path : C:\test.xml
Node : root
Alias : xml
Urn : http://www.w3.org/XML/1998/namespace
Url :
 
Path : C:\test.xml
Node : root
Alias : xsi
Urn : http://www.w3.org/2001/XMLSchema-instance
Url :
#>


[CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param(
# The string to check.
[Parameter(ParameterSetName='Xml',Position=0,Mandatory=$true,ValueFromPipeline=$true)][xml] $Xml,
# A file to check.
[Parameter(ParameterSetName='Path',Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('FullName')][ValidateScript({Test-Path $_ -PathType Leaf})][string] $Path
)
Process
{
    $xmlsrc = if($Path) {@{Path=$Path}} else {@{Xml=$Xml}}
    foreach($element in (Select-Xml '//*[@xsi:schemaLocation]' @xmlsrc -Namespace @{
        xsi='http://www.w3.org/2001/XMLSchema-instance'} |Select-Object -ExpandProperty Node))
    {
        $nonsatt = $element.Attributes.GetNamedItem('noNamespaceSchemaLocation',
            'http://www.w3.org/2001/XMLSchema-instance')
        $nons = if($nonsatt) {$nonsatt.Value}
        [string[]]$locations = $element.Attributes.GetNamedItem('schemaLocation',
            'http://www.w3.org/2001/XMLSchema-instance').Value.Trim() -split '\s+'
        if($locations.Length -band 1) {Write-Warning "XML schemaLocation has $($locations.Length) entries"}
        $schemaLocation = @{}
        for($i = 1; $i -lt $locations.Length; $i += 2)
        {
            $schemaLocation[$locations[$i-1]] = $locations[$i]
        }
        $nav = $element.CreateNavigator()
        [void]$nav.MoveToFollowing('Element')
        $ns = $nav.GetNamespacesInScope('All')
        foreach($ns in $ns.GetEnumerator())
        {
            [pscustomobject]@{
                Path  = $Path
                Node  = $element
                Alias = $ns.Key
                Urn   = $ns.Value
                Url   = if($schemaLocation.ContainsKey($ns.Value)) {$schemaLocation[$ns.Value]} else {$nons}
            }
        }
    }
}

}

function Resolve-XPath
{
<#
.SYNOPSIS
Returns the XPath of the location of an XML node.
 
.INPUTS
System.Xml.XmlNode or property of that type named XmlNode or Node.
 
.OUTPUTS
System.Management.Automation.PSCustomObject with the following properties:
 
* XPath: The XPath that locates the node.
* Namespace: The namespace table used to select the node.
 
.FUNCTIONALITY
XML
 
.LINK
https://docs.microsoft.com/dotnet/api/system.xml.xmlnode
 
.EXAMPLE
'<a><b c="value"/></a>' |Select-Xml //@c |Resolve-XPath
 
/a/b/@c
 
.EXAMPLE
'<a>one<!-- two -->three</a>' |Select-Xml '//text()' |Resolve-XPath
 
/a/text()[1]
/a/text()[2]
#>


[CmdletBinding()][OutputType([string])] Param(
# An XML node to retrieve the XPath for.
[Alias('Node')][Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[Xml.XmlNode] $XmlNode
)
Begin
{
    function Test-NodeMatch([Xml.XmlNode]$ReferenceNode,[Xml.XmlNode]$DifferenceNode)
    {
        return $ReferenceNode.get_NodeType() -eq $DifferenceNode.get_NodeType() -and
            $ReferenceNode.get_LocalName() -eq $DifferenceNode.get_LocalName() -and
            $ReferenceNode.get_NamespaceURI() -eq $DifferenceNode.get_NamespaceURI()
    }

    function Measure-XmlNodePosition([Parameter(Position=0,Mandatory=$true)][Xml.XmlNode]$Node)
    {
        if(!($Node.PreviousSibling -or $Node.NextSibling)) {return}
        for($i,$n = 0,$Node; $n; $n = $n.PreviousSibling) {if(Test-NodeMatch $n $Node) {$i++}}
        if($i -gt 1) {return "[$i]"}
        for($i,$n = 0,$Node.NextSibling; $n; $n = $n.NextSibling) {if(Test-NodeMatch $n $Node) {$i++; break}}
        if($i -gt 0) {return '[1]'}
        else {return}
    }

    function Get-NodeName([Parameter(Position=0,Mandatory=$true)][Xml.XmlNode]$Node,
        [Parameter(Position=1)][Collections.Hashtable]$Namespace=@{})
    {
        if($Node.get_NamespaceURI() -and !$Node.get_Prefix() -and ($Node.get_NodeType() -ne 'Attribute' -or
            $Node.get_NamespaceURI() -ne $Node.OwnerElement.get_NamespaceURI()))
        {
            if($Node.get_NamespaceURI() -in $Namespace.Values)
            {
                $prefix = $Namespace.GetEnumerator() |Where-Object Value -eq $Node.get_NamespaceURI() |Select-Object -First 1 -exp Key
            }
            else
            {
                $prefix = ([uri]$Node.get_NamespaceURI()).Segments |Where-Object {$_ -match '\A[A-Za-z]\w*\z'} |Select-Object -Last 1
                if(!$prefix) {$prefix = 'ns'}
                while($Namespace.ContainsKey($prefix)) {$prefix += Get-Random -Maximum 99}
                $Namespace.Add($prefix,$Node.get_NamespaceURI())
            }
            return $prefix + ':' + $Node.get_LocalName()
        }
        else
        {
            if($Node.get_NamespaceURI() -and !$Namespace.ContainsKey($Node.get_Prefix()))
            {$Namespace.Add($Node.get_Prefix(),$Node.get_NamespaceURI())}
            return $Node.get_Name()
        }
    }

    function Resolve-XmlNode([Parameter(Position=0,Mandatory=$true)][Xml.XmlNode]$Node,
        [Parameter(Position=1)][Collections.Hashtable]$Namespace=@{},
        [switch]$AsObject)
    {
        $name = Get-NodeName $Node $Namespace
        $xpath = switch($Node.get_NodeType())
        {
            Attribute {"$(Resolve-XmlNode $Node.OwnerElement $Namespace)/@$name"}
            CDATA {"$(Resolve-XmlNode $Node.ParentNode)/text()$(Measure-XmlNodePosition $Node)"}
            Comment {"$(Resolve-XmlNode $Node.ParentNode)/comment()$(Measure-XmlNodePosition $Node)"}
            Document {if($AsObject){'/'}else{$null}}
            Element {"$(Resolve-XmlNode $Node.ParentNode $Namespace)/$name$(Measure-XmlNodePosition $Node)"}
            ProcessingInstruction {
                "$(Resolve-XmlNode $Node.ParentNode)/processing-instruction('$name')$(Measure-XmlNodePosition $Node)"}
            SignificantWhitespace {"$(Resolve-XmlNode $Node.ParentNode)/text()$(Measure-XmlNodePosition $Node)"}
            Text {"$(Resolve-XmlNode $Node.ParentNode)/text()$(Measure-XmlNodePosition $Node)"}
            Whitespace {"$(Resolve-XmlNode $Node.ParentNode)/text()$(Measure-XmlNodePosition $Node)"}
            default {$null}
        }
        if($AsObject) {[pscustomobject]@{XPath=$xpath;Namespace=$Namespace}} else {$xpath}
    }
}
Process
{
    Resolve-XmlNode $XmlNode -AsObject
}

}

function Test-Xml
{
<#
.SYNOPSIS
Try parsing text as XML, and validating it if a schema is provided.
 
.INPUTS
System.String containing a file path or potential XML data.
 
.OUTPUTS
System.Boolean indicating the XML is parseable, or System.String containing the
parse error if -ErrorMessage is present and the XML isn't parseable.
 
.FUNCTIONALITY
XML
 
.LINK
https://www.w3.org/TR/xmlschema-1/#xsi_schemaLocation
 
.LINK
https://docs.microsoft.com/dotnet/api/system.xml.xmlresolver
 
.LINK
https://docs.microsoft.com/dotnet/api/system.xml.schema.validationeventhandler
 
.LINK
Resolve-XmlSchemaLocation
 
.EXAMPLE
Test-Xml -Xml '</>'
 
False
#>


[CmdletBinding()][OutputType([bool])] Param(
# A file to check.
[Parameter(ParameterSetName='Path',Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)]
[ValidateScript({Test-Path $_ -PathType Leaf})][Alias('FullName')][string] $Path,
# The string to check.
[Parameter(ParameterSetName='Xml',Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[ValidateScript({!(Test-Path $_ -PathType Leaf)})][string] $Xml,
# A hashtable of schema namespaces to schema locations (in addition to the xsi:schemaLocation attribute).
[Alias('Schemas')][hashtable] $Schemata,
# Indicates that XML Schema validation should not be performed, only XML well-formedness will be checked.
[Alias('NoValidation')][switch] $SkipValidation,
# Indicates that well-formedness or validation errors will result in warnings being written.
[Alias('ShowWarnings')][switch] $Warnings,
<#
When present, returns the well-formedness or validation error messages instead of a boolean value,
or nothing if successful. This effectively reverses the truthiness of the return value.
#>

[Alias('NotSuccessful')][switch] $ErrorMessage
)
Begin
{
    function Add-Schema([Xml.Schema.XmlSchemaSet]$set,[string]$urn,[string]$url)
    {
        Write-Verbose "Adding $url for $urn"
        try {[void]$set.Add($urn,$url)}
        catch
        {
            if(!(Test-Path $url)) {Write-Error "Error adding $url for $urn"}
            else
            {
                $s = [Xml.Schema.XmlSchema]::Read( [IO.File]::OpenRead($url), {throw $_.Exception} )
                $s.Compile( {
                    if($_.Severity -eq [Xml.Schema.XmlSeverityType]::Error) {Write-Error $_.Message; throw $_.Exception}
                    else {Write-Warning $_.Message}
                } )
            }
        }
    }
}
Process
{
    if($Path){$Xml= Get-Content $Path -Raw}
    try{[xml]$x = $Xml}
    catch [Management.Automation.RuntimeException]
    {
        if($Warnings) {Write-Warning $_.Exception.Message}
        if(!$ErrorMessage) {return $false}
        else {return $_.Exception.InnerException.InnerException.Message}
    }
    if($SkipValidation) {return !$ErrorMessage}
    #$x.Schemas.XmlResolver = New-Object Xml.XmlUrlResolver # this should be the default, but can't set a base URL
    # kludgy hack to try and address XmlUrlResolver using env working dir:
    [Environment]::CurrentDirectory = if($Path) {Resolve-Path $Path |Split-Path} else {$PWD}
    $xmlsrc = if($Path) {@{Path=$Path}} else {@{Xml=$Xml}}
    foreach($schema in Resolve-XmlSchemaLocation @xmlsrc |Where-Object {$_.Url}) {Add-Schema $x.Schemas $schema.Urn $schema.Url}
    if($Schemata) {foreach($schema in $Schemata.GetEnumerator()) {Add-Schema $x.Schemas $schema.Key $schema.Value}}
    if($x.Schemas.Count -eq 0) {return !$ErrorMessage}
    $x.Schemas.Schemas().SourceUri |ForEach-Object {Write-Verbose "Added schema $_"}
    $Script:validationErrors = @()
    $Script:warn = $Warnings
    $x.Validate({ if($Script:warn) {Write-Warning $_.Message}; $Script:validationErrors += @($_.Message) })
    if($ErrorMessage) {return $Script:validationErrors}
    else {return !($Script:validationErrors)}
}

}
Export-ModuleMember -Function Compare-Xml,Convert-Xml,ConvertFrom-EscapedXml,ConvertFrom-XmlElement,ConvertTo-XmlElements,Format-Xml,Get-XmlNamespaces,Merge-XmlSelections,New-NamespaceManager,Resolve-XmlSchemaLocation,Resolve-XPath,Test-Xml