Generating tag cloud using the Content Query Web Part


By now you should know how powerful the Content Query Web Part (CQWP) provided with Microsoft Office SharePoint Server (MOSS) 2007 is. If you’ve been following this blog for a while it should be really difficult to surprise you with cool new things you could do with nothing more than the standard CQWP. And yet, I think I make a good chance here: did you know that you can use the standard Content Query Web Part to create a tag cloud?

Tag Clouds and SharePoint

Tag Clouds provide an alternative way of navigating through your site. Using a tag cloud you can help your visitors discover the content on your site. By using different font sizes (or any other kind of styling) you can mark the most important (either if it’s the most often visited, the category contains most articles, etc.) so they can easily get to that information.

You can find tag clouds on many sites hosted on many different Content Management Systems (CMS) on the Internet. It’s surprising though, that SharePoint doesn’t contain a standard Tag Cloud Web Part, does it? Just recently I have done some research and found out that you could actually create a Tag Cloud using nothing more than the standard Content Query Web Part provided with MOSS 2007.

Requirements

We want to change this:

An aggregation of Press Releases

into this:

Tag Cloud Web Part

The only limitation we have is that we cannot use any custom code at all (that means no subclassed CQWP and no custom XPath functions as well). All we are allowed to do is to use the standard Content Query Web Part and change the XSLT.

As the data source we will use some press releases (also available with the standard MOSS 2007 Publishing Site). Each press release belongs to exactly one category (custom choice field):

Overview of the Press Releases used as data source for the Tag Cloud Web Part

What we want to do is to create a Tag Cloud of all categories in which we will highlight the categories with the most press releases.

Let’s get to work

To simplify the whole process let’s break it into some steps.

First of all we need to get all the different categories assigned to press releases. Because we’re using a custom choice field here, we could simply hard code them in the XSLT. That would however limit our Tag Cloud: in the Category choice field we have allowed fill-in choices so that the content editors can create new categories if needed. So instead of changing the XSLT every time a content editor adds a new category, let’s retrieve them dynamically.

The next step is to perform a count: how many times each category has been used. In other words: how many different press releases are there in every category. Based on that number we will determine the relative importance of every category.

Then we need to determine which category contains the most posts. This will be our measure point to determine the relative importance of every category.

To make the process a bit more understandable let’s start off with a simple XML file, make the XSLT work and then move it to the Content Query Web Part.  I’m going to base the case on the following XML file:

<?xml version="1.0" encoding="utf-8"?>
<Rows>
  <Row Category="SharePoint"/>
  <Row Category="Content Query Web Part"/>
  <Row Category="Structured and Repeatable Deployment"/>
  <Row Category="WCM"/>
  <Row Category="SharePoint"/>
  <Row Category="SharePoint"/>
  <Row Category="Structured and Repeatable Deployment"/>
  <Row Category="SharePoint"/>
  <Row Category="SharePoint"/>
  <Row Category="WCM"/>
  <Row Category="WCM"/>
  <Row Category="Content Query Web Part"/>
  <Row Category="Content Query Web Part"/>
  <Row Category="Content Query Web Part"/>
  <Row Category=".NET"/>
</Rows>

Later on we will modify the Tag Cloud XSLT to work in the CQWP.

Getting different categories

We can retrieve all the different categories using one single XPath function (→ means a forced line break. In the real code it would be in one line):

<xsl:variable name="TagsArray"
select="Row[not(@Category=preceding-sibling::→
Row/@Category)]/@Category"/>

Although it seems pretty complex it works very simple: for each node the value of the Category attribute is being compared the value of the Category attribute of the previous sibling node. Because the function is included in a not clause we will get all the distinct categories. Because it’s just the beginning and we want to do something with the categories, let’s store them in a variable.

How many press releases are there in each category?

If you were asked do perform a similar count in .NET code, you would most likely create a Dictionary of string and int, where the key would represent a category and the value the number of press releases. Using a for each loop you would step through all the categories and store the counts in a new variable. The bad news is that you cannot do such thing in XSLT. Once you set a value of a variable, you cannot change it. So how to get this done?

One solution I thought of was creating an XML document with all the categories and the count for each category:

<tags>
  <tag Name="SharePoint" Count="3"/>
  <tag Name="Content Query Web Part" Count="1"/>
  // ...
</tags>

I have created the following template:

<xsl:template name="Imtech.CountPerTag">
  <xsl:param name="Tags"/>
  <tags>
    <xsl:for-each select="$Tags">
      <xsl:variable name="Name" select="self::node()"/>
      <tag>
        <xsl:attribute name="Name">
          <xsl:value-of select="$Name"/>
        </xsl:attribute>
        <xsl:attribute name="Count">
          <xsl:value-of
            select="count(/Rows/Row[@Category=$Name])"/>
        </xsl:attribute>
      </tag>
    </xsl:for-each>
  </tags>
</xsl:template>

and then called it passing the TagsArray variable as Tags parameter:

<xsl:variable name="TagsRawXml">
  <xsl:call-template name="Imtech.CountPerTag">
    <xsl:with-param name="Tags" select="$TagsArray"/>
  </xsl:call-template>
</xsl:variable>

Once again let’s store the results in a variable. We’re not there yet and we will do some more processing before we will get a tag cloud.

If you look carefully at the last snippet you see, that I called the variable TagsRawXml. So why the Raw part and not just TagsXml? The reason for this is pretty simple. The Imtech.CounterPerTag produces string output. Before we can process it as an XML document, we have to convert it to a node set using the msxsl:node-set() function:

<xsl:variable name="Tags" select="msxsl:node-set($TagsRawXml)"/>

Now we know for each category how many press releases it has, we can move on and cover our next challenge.

Determining the maximum value

Tag Cloud is all about relativity. One of the items determines the upper boundary and then all the other items are being formatted depending on their relative importance comparing to the upper boundary.

XSLT has an XPath max function. The downside is that it returns the maximum value from a list of arguments, not the maximum value of an attribute from a node set. In other words, we have to create a custom solution to get things done:

<xsl:variable name="MaxValue"
select="$Tags/tags/tag[not(@Count &lt;= →
preceding-sibling::tag/@Count) and →
not(@Count &lt;= following-sibling::tag/@Count)]/@Count"/>

Once again it seems pretty complex but it isn’t: the value of every Count attribute is being compared with the value of the previous and the next Count attribute and is being returned only if it’s bigger than the two other Count attributes.

So far we have done quite a few things. Let’s wrap it up and check out the results:

List of tags generated using XSLT

<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="1.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:msxsl="urn:schemas-microsoft-com:xslt"
    exclude-result-prefixes="msxsl">
  <xsl:output method="html" indent="yes"/>

  <xsl:template match="/Rows">
    <xsl:call-template name="Imtech.TagCloud"/>
  </xsl:template>

  <xsl:template name="Imtech.TagCloud">
    <xsl:variable name="TagsArray"
      select="Row[not(@Category=preceding-sibling::→
      Row/@Category)]/@Category"/>
    <xsl:variable name="TagsRawXml">
      <xsl:call-template name="Imtech.CountPerTag">
        <xsl:with-param name="Tags" select="$TagsArray"/>
      </xsl:call-template>
    </xsl:variable>
    <xsl:variable name="Tags"
      select="msxsl:node-set($TagsRawXml)"/>
    <xsl:variable name="MaxValue"
      select="$Tags/tags/tag[not(@Count &lt;= →
      preceding-sibling::tag/@Count) and →
      not(@Count &lt;= following-sibling::tag/@Count)]/@Count"/>
    <xsl:for-each select="$Tags/tags/tag">
      <a>
        <xsl:attribute name="href">
          <xsl:value-of
            select="concat(→
            '/Pages/SearchResults.aspx?k=', @Name)"/>
        </xsl:attribute>
        <xsl:value-of select="@Name"/>
      </a>
      <xsl:text disable-output-escaping="yes">
        <![CDATA[&nbsp;]]></xsl:text>
    </xsl:for-each>
  </xsl:template>
  <xsl:template name="Imtech.CountPerTag">
    <xsl:param name="Tags"/>
    <tags>
      <xsl:for-each select="$Tags">
        <xsl:variable name="Name" select="self::node()"/>
        <tag>
          <xsl:attribute name="Name">
            <xsl:value-of select="$Name"/>
          </xsl:attribute>
          <xsl:attribute name="Count">
            <xsl:value-of
              select="count(/Rows/Row[@Category=$Name])"/>
          </xsl:attribute>
        </tag>
      </xsl:for-each>
    </tags>
  </xsl:template>
</xsl:stylesheet>

We are not there just yet: so far we have managed to get the list of all the distinct categories attached to the press releases. But as we wanted to create a Tag Cloud, we need to do something with formatting. At some point we retrieved the upper boundary for our Tag Cloud – the category with the most press releases. Let’s use it to determine the font size for each category depending on how many press releases it has.

First let’s create a template to get us the right formatting:

<xsl:template name="Imtech.GetRelativeFontSize">
  <xsl:param name="CurrentValue"/>
  <xsl:param name="MaxValue"/>
  <xsl:param name="relativeValue"
    select="($CurrentValue div $MaxValue)*100"/>
  <xsl:choose>
    <xsl:when test="$relativeValue &lt; 14">xx-small</xsl:when>
    <xsl:when test="$relativeValue &lt; 28">x-small</xsl:when>
    <xsl:when test="$relativeValue &lt; 42">small</xsl:when>
    <xsl:when test="$relativeValue &lt; 56">medium</xsl:when>
    <xsl:when test="$relativeValue &lt; 70">large</xsl:when>
    <xsl:when test="$relativeValue &lt; 84">x-large</xsl:when>
    <xsl:otherwise>xx-large</xsl:otherwise>
  </xsl:choose>
</xsl:template>

The relevance of a category comparing to the upper boundary is being calculated as a percentage. To keep it simple I have used the seven predefined font-sizes as formatting but nothing stops you to use things like colors, different font weights, styling, etc.

Let’s extend our Tag Cloud template:

<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="1.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:msxsl="urn:schemas-microsoft-com:xslt"
    exclude-result-prefixes="msxsl">
  <xsl:output method="html" indent="yes"/>

  <xsl:template match="/Rows">
    <xsl:call-template name="Imtech.TagCloud"/>
  </xsl:template>

  <xsl:template name="Imtech.TagCloud">
    <xsl:variable name="TagsArray"
      select="Row[not(@Category=preceding-sibling::→
      Row/@Category)]/@Category"/>
    <xsl:variable name="TagsRawXml">
      <xsl:call-template name="Imtech.CountPerTag">
        <xsl:with-param name="Tags" select="$TagsArray"/>
      </xsl:call-template>
    </xsl:variable>
    <xsl:variable name="Tags"
      select="msxsl:node-set($TagsRawXml)"/>
    <xsl:variable name="MaxValue"
      select="$Tags/tags/tag[not(@Count &lt;= →
      preceding-sibling::tag/@Count) and →
      not(@Count &lt;= following-sibling::→
      tag/@Count)]/@Count"/>
    <xsl:for-each select="$Tags/tags/tag">
      <xsl:variable name="fontSize">
        <xsl:call-template name="Imtech.GetRelativeFontSize">
          <xsl:with-param name="CurrentValue" select="@Count"/>
          <xsl:with-param name="MaxValue" select="$MaxValue"/>
        </xsl:call-template>
      </xsl:variable>
      <a>
        <xsl:attribute name="style">
          <xsl:value-of
            select="concat('font-size: ', $fontSize)"/>
        </xsl:attribute>
        <xsl:attribute name="href">
          <xsl:value-of
            select="concat('/Pages/SearchResults.aspx?k=', →
            @Name)"/>
        </xsl:attribute>
        <xsl:value-of select="@Name"/>
      </a>
      <xsl:text disable-output-escaping="yes">
        <![CDATA[&nbsp;]]></xsl:text>
    </xsl:for-each>
  </xsl:template>
  <xsl:template name="Imtech.CountPerTag">
    <xsl:param name="Tags"/>
    <tags>
      <xsl:for-each select="$Tags">
        <xsl:variable name="Name" select="self::node()"/>
        <tag>
          <xsl:attribute name="Name">
            <xsl:value-of select="$Name"/>
          </xsl:attribute>
          <xsl:attribute name="Count">
            <xsl:value-of
              select="count(/Rows/Row[@Category=$Name])"/>
          </xsl:attribute>
        </tag>
      </xsl:for-each>
    </tags>
  </xsl:template>
  <xsl:template name="Imtech.GetRelativeFontSize">
    <xsl:param name="CurrentValue"/>
    <xsl:param name="MaxValue"/>
    <xsl:param name="relativeValue"
      select="($CurrentValue div $MaxValue)*100"/>
    <xsl:choose>
      <xsl:when test="$relativeValue &lt; 14">xx-small</xsl:when>
      <xsl:when test="$relativeValue &lt; 28">x-small</xsl:when>
      <xsl:when test="$relativeValue &lt; 42">small</xsl:when>
      <xsl:when test="$relativeValue &lt; 56">medium</xsl:when>
      <xsl:when test="$relativeValue &lt; 70">large</xsl:when>
      <xsl:when test="$relativeValue &lt; 84">x-large</xsl:when>
      <xsl:otherwise>xx-large</xsl:otherwise>
    </xsl:choose>
  </xsl:template>
</xsl:stylesheet>

and check out the results:

tagcloud1

Looks better doesn’t it? Now we have it all working in Visual Studio, let’s move it to the Content Query Web Part.

Content Query Web Part and XSLT

The Content Query Web Part uses three different XSL files to do the data rendering:

  • ItemStyle.xsl which contains different styles for rendering items
  • Header.xsl which contains different styles for rendering groups
  • ContentQueryMain.xsl which is responsible for rendering the body of the web part, applying the proper headers and items formatting etc.

The basic idea is that the ContentQueryMain is being applied once to the query result and the item styles from ItemStyle.xsl are being applied to every single item (Row in the query results XML).

Looking at the template you can see that we’re starting from the top and perform the whole process of selection ourselves. The first thing you might think of would to include the whole template in the ContentQueryMain.xsl and replace the default rendering with our Tag Cloud template. While you could do that, it would mean that the Content Query Web Part with that XSL attached to it wouldn’t be capable of rendering anything else than a Tag Cloud. There is a more flexible way to get it working.

First of all we still need to include the whole template in the ContentQueryMain.xsl. Additionally we will define an empty Imtech.TagCloud template in ItemStyle.xsl so that we can select the formatting using the Content Query Web Part presentation settings:

Choosing the Imtech.TagCloud template in the Content Query Web Part presentation settings

The Content Query Web Part attaches the information about the selected item style to each query result row as an extra attribute (Style):

Content Query Web Part includes the information about selected item style as an extra attribute called Style

Knowing that we can extend the ContentQueryMain with conditional rendering: if the chosen Item style is Imtech.TagCloud the CQWP should use our template and otherwise it should stick to the default rendering process.

If you save the changes done to the ContentQueryMain.xsl and reload the page you will get an error:

CQWP displays an error after including the Imtech.TagCloud template in the ContentQueryMain.xsl file

Although the Content Query Web Part supports functions from the msxsl namespace, the ContentQueryMain.xsl doesn’t reference it. You can fix this error by including it in the header of the ContentQueryMain.xsl file:

Including the msxsl namespace in the ContentQueryMain.xsl namespace

Let’s have a look at the results again:

Tag Cloud is being displayed properly after adding the msxsl namespace

Summary

You can do really amazing things with the Content Query Web Part. Because it uses XSLT it allows you to customize the data presentation in many different ways. Even if you’re not allowed to subclass it and extend it with custom XPath functions, you can do some great customizations with the standard functionality.

Complete template

In ItemStyle.xsl

<xsl:template name="Imtech.TagCloud"
  match="Row[@Style='Imtech.TagCloud']" mode="itemstyle"/>

In ContentQueryMain.xsl

File Header:

<xsl:stylesheet
    version="1.0"
    exclude-result-prefixes="x xsl cmswrt cbq msxsl" 
    xmlns:x="http://www.w3.org/2001/XMLSchema"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:cmswrt="http://schemas.microsoft.com/WebPart/v3/Publishing/runtime"
    xmlns:cbq="urn:schemas-microsoft-com:ContentByQueryWebPart"
    xmlns:msxsl="urn:schemas-microsoft-com:xslt">

OuterTemplate template:

...
<xsl:choose>
  <xsl:when test="$IsEmpty">
    <xsl:call-template name="OuterTemplate.Empty" >
      <xsl:with-param name="EditMode" select="$cbq_iseditmode" />
    </xsl:call-template>
  </xsl:when>
  <xsl:otherwise>
    <xsl:choose>
      <xsl:when
        test="/dsQueryResponse/Rows/Row[1]/→
        @Style='Imtech.TagCloud'">
        <td>
          <xsl:call-template name="Imtech.TagCloud" />
        </td>
      </xsl:when>
      <xsl:otherwise>
        <xsl:call-template name="OuterTemplate.Body">
          <xsl:with-param name="Rows" select="$Rows" />
          <xsl:with-param name="FirstRow" select="1" />
          <xsl:with-param name="LastRow" select="$RowCount" />
        </xsl:call-template>
      </xsl:otherwise>
    </xsl:choose>
  </xsl:otherwise>
</xsl:choose>
...

At the end of the file:

<xsl:template name="Imtech.TagCloud">
  <xsl:variable name="TagsArray"
    select="/dsQueryResponse/Rows/Row[→
    not(@Category=preceding-sibling::Row/@Category)]/@Category"/>
  <xsl:variable name="TagsRawXml">
    <xsl:call-template name="Imtech.CountPerTag">
      <xsl:with-param name="Tags" select="$TagsArray"/>
    </xsl:call-template>
  </xsl:variable>
  <xsl:variable name="Tags" select="msxsl:node-set($TagsRawXml)"/>
  <xsl:variable name="MaxValue"
    select="$Tags/tags/tag[not(@Count &lt;= →
    preceding-sibling::tag/@Count) and →
    not(@Count &lt;= following-sibling::tag/@Count)]/@Count"/>
  <xsl:for-each select="$Tags/tags/tag">
    <xsl:variable name="fontSize">
      <xsl:call-template name="Imtech.GetRelativeFontSize">
        <xsl:with-param name="CurrentValue" select="@Count"/>
        <xsl:with-param name="MaxValue" select="$MaxValue"/>
      </xsl:call-template>
    </xsl:variable>
    <a>
      <xsl:attribute name="style">
        <xsl:value-of select="concat('font-size: ', $fontSize)"/>
      </xsl:attribute>
      <xsl:attribute name="href">
        <xsl:value-of
          select="concat('/Pages/SearchResults.aspx?k=', @Name)"/>
      </xsl:attribute>
      <xsl:value-of select="@Name"/>
    </a>
    <xsl:text disable-output-escaping="yes">
      <![CDATA[&nbsp;]]></xsl:text>
  </xsl:for-each>
</xsl:template>
<xsl:template name="Imtech.CountPerTag">
  <xsl:param name="Tags"/>
  <tags>
    <xsl:for-each select="$Tags">
      <xsl:variable name="Name" select="self::node()"/>
      <tag>
        <xsl:attribute name="Name">
          <xsl:value-of select="$Name"/>
        </xsl:attribute>
        <xsl:attribute name="Count">
          <xsl:value-of
            select="count(/dsQueryResponse/Rows/Row[→
            @Category=$Name])"/>
        </xsl:attribute>
      </tag>
    </xsl:for-each>
  </tags>
</xsl:template>
<xsl:template name="Imtech.GetRelativeFontSize">
  <xsl:param name="CurrentValue"/>
  <xsl:param name="MaxValue"/>
  <xsl:param name="relativeValue"
    select="($CurrentValue div $MaxValue)*100"/>
  <xsl:choose>
    <xsl:when test="$relativeValue &lt; 14">xx-small</xsl:when>
    <xsl:when test="$relativeValue &lt; 28">x-small</xsl:when>
    <xsl:when test="$relativeValue &lt; 42">small</xsl:when>
    <xsl:when test="$relativeValue &lt; 56">medium</xsl:when>
    <xsl:when test="$relativeValue &lt; 70">large</xsl:when>
    <xsl:when test="$relativeValue &lt; 84">x-large</xsl:when>
    <xsl:otherwise>xx-large</xsl:otherwise>
  </xsl:choose>
</xsl:template>

Technorati Tags: SharePoint, SharePoint 2007, MOSS 2007

Others found also helpful: