views:

59

answers:

4

I have an XML file with information, for example:

<letter>
  <name>Test</name>
  <age>20</age>
  <me>Me</me>
</letter>

And then I have an text template like:

Dear $name,

some text with other variables like $age or $name again

greatings $me

When using xslt to transform the XML to the plain text letter I can use something like:

<xsl:text>Dear </xsl:text><xsl:value-of select="name"/><xsl:text>

some text with other variables like </xsl:text>
<xsl:value-of select="age"/><xsl:text> or </xsl:text>
<xsl:value-of select="name"/><xsl:text> again

greatings </xsl:text><xsl:value-of select="me"/>

But when I get more and more variables and more text this becomes a nightmare to enter and to maintain.

Is there some way to do this in a cleaner way using xslt? I would prefer if I could just use the text template I used as an example above and have $name and $age replaced with the correct values.

+2  A: 

You can do this:

<xsl:stylesheet 
  version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
>
  <xsl:output method="text" />

  <xsl:variable name="placeholderText"
>Dear $name,

some text with other variables like $age or $name again,
the $undefined will not be replaced.

greatings $me</xsl:variable>

  <xsl:template match="letter">
    <xsl:call-template name="expand-placeholders">
      <xsl:with-param name="text"   select="$placeholderText" /> 
      <xsl:with-param name="values" select="*" /> 
    </xsl:call-template>
  </xsl:template>

  <xsl:template name="expand-placeholders">
    <xsl:param name="text"   select="''" />
    <xsl:param name="values" select="false()" />
    <xsl:choose>
      <xsl:when test="contains($text, '$') and $values">
        <xsl:variable name="head" select="substring-before($text, '$')" />
        <xsl:variable name="curr" select="substring-after($text, '$')" />
        <!-- find the longest matching value name... -->
        <xsl:variable name="valName">
          <xsl:for-each select="$values[starts-with($curr, name())]">
            <xsl:sort select="string-length(name())" data-type="number" order="descending" />
            <xsl:if test="position() = 1">
              <xsl:value-of select="name()" />
            </xsl:if>
          </xsl:for-each>
        </xsl:variable>
        <!-- ... and select the appropriate placeholder element -->
        <xsl:variable name="val" select="$values[name() = $valName][1]" />
        <xsl:variable name="tail">
          <xsl:choose>
            <xsl:when test="$val">
              <xsl:value-of select="substring-after($curr, name($val))" />
            </xsl:when>
            <xsl:otherwise>
              <xsl:value-of select="$curr" />
            </xsl:otherwise>
          </xsl:choose>
        </xsl:variable>

        <xsl:value-of select="$head" />
        <xsl:if test="not($val)">$</xsl:if>
        <xsl:value-of select="$val" />  

        <xsl:call-template name="expand-placeholders">
          <xsl:with-param name="text"   select="$tail" /> 
          <xsl:with-param name="values" select="$values" /> 
        </xsl:call-template>
      </xsl:when>
      <xsl:otherwise>
        <xsl:value-of select="$text" />  
      </xsl:otherwise>
    </xsl:choose>
  </xsl:template>
</xsl:stylesheet>

Output against your XML sample:

Dear Test,

some text with other variables like 20 or Test again,
the $undefined will not be replaced.

greatings Me
Tomalak
+1 Produces the desired output with the given inputs. The only thing to look out for is that `starts-with()` might generate some false positive matches(if there were placeholders called `country` and `countryCode`), but works great for the given sample data.
Mads Hansen
@Mads Hansen: Yes, I've already thought about that. The selector for `$val` should be changed to select the element with the longest name.
Tomalak
@Mads Hansen: I've made the appropriate change to take the *longest* matching placeholder name instead of the first matching one. Now `$country` and `$countryCode` will be properly distinguished.
Tomalak
+1 for actually using the $name placeholders. However I prefer the other solution using XML tags.
rve
+1  A: 

As @Mads Hansen suggests, if you can use a XML based template this can be solved in an easier, and imo better way.

Here's a solution with a XML template as input.

XML template as input:

<?xml version="1.0" encoding="UTF-8"?>
<letter>Dear <name/>,

some text with other variables like <age/> or <name/> again

greatings <me/></letter>

XSLT:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:vars="my.variables">

  <xsl:output method="text"/>
  <xsl:preserve-space elements="letter"/>

  <vars:letter>
    <name>Test</name>
    <age>20</age>
    <me>Me</me>
  </vars:letter>

  <xsl:template match="name|age|me">
    <xsl:variable name="name" select="local-name()"/>
    <xsl:value-of select="document('')/*/vars:letter/*[local-name() = $name]"/>
  </xsl:template>

</xsl:stylesheet>

Output:

Dear Test,

some text with other variables like 20 or Test again

greatings Me

If you use the XML template as input, this is one possible way to solve it. The different values to fill you template with could of course be fetched from an external XML file with fn:document() aswell:

<xsl:value-of select="document('path/to/file.xml')/letter/*[local-name() = $name]"/>

Update: As @Tomlak commented the above solution isn't very flexible so here is an updated one:

XSLT:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:vars="my.variables">

  <xsl:output method="text"/>
  <xsl:preserve-space elements="letter"/>

  <vars:letter>
    <name>Test</name>
    <age>20</age>
    <me>Me</me>
  </vars:letter>

  <xsl:template match="letter">
    <xsl:apply-templates/>
  </xsl:template>

  <xsl:template match="letter/*">
    <xsl:variable name="name" select="local-name()"/>
    <xsl:variable name="content" select="document('')/*/vars:letter/*[local-name() = $name]"/>
    <xsl:value-of select="$content"/>
    <xsl:if test="not($content)">
      <xsl:message>
        <xsl:text>Found unknown variable in template: </xsl:text>
        <xsl:value-of select="concat('&lt;', local-name(), '/&gt;')" disable-output-escaping="yes"/>
      </xsl:message>
      <xsl:value-of select="concat('&lt;', local-name(), '/&gt;')"/>
    </xsl:if>
  </xsl:template>

</xsl:stylesheet>

Note that this is a different approach since it uses the template as input document and not the list of variables. Why? To me it makes more sense to use the letter template as input since it's actully that document you transform and get as output.

Per T
This is compact but inflexible because you must change the source every time a new variable is created.
Tomalak
@Tomalak: You're right about that, but it does what he asked for. See updated stylesheet for a more versatile solution.
Per T
@Per: Still unflexible because I take it that the placeholders variables are… well variable. :-) Then again it is easy to load them from a different document instead of using a custom namecespaced element in the stylesheet, so +1 from me.
Tomalak
@Tomlak: Yes, that's the idea. Changing the `document('')` to using an external document for the placeholder variables would definetly make the code easier to maintain.
Per T
+2  A: 

One possible solution would be to change your template file to be an XML configuration, like this:

<?xml version="1.0" encoding="UTF-8"?>
<template>
    Dear <name/>,

    some text with other variables like <age/> or <name/> again

    greatings <me/>
</template>

Assuming that the above XML template is named template.xml and in the same directory as the XSLT below:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:xs="http://www.w3.org/2001/XMLSchema"
    xmlns:xd="http://www.oxygenxml.com/ns/doc/xsl"
    exclude-result-prefixes="xs xd"
    version="1.0">
<xsl:output method="text"/>

<!--Load the template document as a variable -->
<xsl:variable name="templateFile" select="document('template.xml')" />
<!--Load the current document in a variable, so that we can reference this file from within template matches on the template.xml content-->    
<xsl:variable name="letter" select="/*" />    

<!--When this stylesheet is invoked, apply templates for the templateFile content (everything inside the template element) -->    
<xsl:template match="/">
    <xsl:apply-templates select="$templateFile/*/node()" />
</xsl:template>

 <!--Match on any of the template placeholder elements and replace with the value from it's corresponding letter document-->
<xsl:template match="template/*">
    <!--set the local-name of the current element as a variable, so that we can use it in the expression below -->
    <xsl:variable name="templateElementName" select="local-name(.)" />
    <!--Find the corresponding letter element that matches this template element placeholder-->
    <xsl:value-of select="$letter/*[local-name()=$templateElementName]" />
</xsl:template>

<!--standard identity template that copies content forward-->
    <xsl:template match="@*|node()">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()" />
        </xsl:copy>
    </xsl:template>

</xsl:stylesheet>

When the XSLT is run against the sample XML file, it produces the following output:

Dear Test,

some text with other variables like 20 or Test again

greatings Me

As @Tomalak pointed out, unmatched placeholder elements would be removed from the output. If you wanted to preserve them, to make it apparent that the XML file did not have a match for placeholder items in the template, you could change the template that matches on the template placeholder elements like this:

 <!--Match on any of the template placeholder elements and replace with the value from it's corresponding letter document-->
<xsl:template match="template/*">
    <!--set the local-name of the current element as a variable, so that we can use it in the expression below -->
    <xsl:variable name="templateElementName" select="local-name(.)" />
    <!--Find the corresponding letter element that matches this template element placeholder-->
    <xsl:variable name="replacementValue" select="$letter/*[local-name()=$templateElementName]" />
    <xsl:choose>
        <xsl:when test="$replacementValue">
            <xsl:value-of select="$replacementValue" />
        </xsl:when>
        <xsl:otherwise>
            <xsl:text>$</xsl:text>
            <xsl:value-of select="local-name()"/>
        </xsl:otherwise>
    </xsl:choose>
</xsl:template>

If there was an unmatched placeholder element, <foo/> for example, then it would appear in the text output as $foo.

Mads Hansen
+1. However, the placeholder template should not touch the unknown elements IMHO. Currently it's removing them.
Tomalak
Hmm, placeholder elements would get "eaten" when serializing to XML anyway, since they are empty. Could be seen as a feature or a bug. I'll add an example of how to preserve unmatched elements.
Mads Hansen
@Mads Hansen: +1 for XML layout recomendation.
Alejandro
+4  A: 

This stylesheet:

<xsl:stylesheet version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:my="my">
    <xsl:output method="text"/>
    <xsl:preserve-space elements="my:layout"/>
    <my:layout>Dear <name/>,

some text with other variables like <age/> or <name/> again

greatings <me/></my:layout>
    <xsl:variable name="vData" select="/"/>
    <xsl:template match="/">
        <xsl:apply-templates select="document('')/*/my:layout/node()"/>
    </xsl:template>
    <xsl:template match="*/*">
        <xsl:value-of select="$vData//*[name()=name(current())]"/>
    </xsl:template>
</xsl:stylesheet>

Output:

Dear Test,

some text with other variables like 20 or Test again

greatings Me

Note: For more complex population pattern (i.e. iteration), check this posts: http://stackoverflow.com/questions/3529852/sitemesh-like-functionality-with-xslt and http://stackoverflow.com/questions/3473108/xslt-layouts-with-dynamic-content-region

Alejandro
+1 Nice solution!
Per T
+1 short and sweet. Very nice.
Mads Hansen
I like this one, very short short. Thanks!
rve
@rve: You are wellcome! Also, take a look at those links.
Alejandro