Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Building and Consuming a Dynamic Sitemap in ASP.NET 2.0

0.00/5 (No votes)
25 Sep 2006 1  
You need to build a dynamic sitemap right from a dataset because you don't have static content on your website.

Sample Image - Breadcrumbs.jpg

Introduction

Sitemaps and breadcrumbs (SiteMapPath) are fantastic. For a dynamic site, they can be crucial, since search engines are historically agnostic to QueryString driven content that is common for dynamic websites. A sitemap can help search engines find this content and index it appropriately, expose breadcrumbs for users, and help users see in one place everything you have to offer them.

The out-of-the-box functionality for Sitemaps with ASP.NET 2.0 is fantastic. Many of the complexities previously associated with them have been addressed; however, while implementing one for our system, we faced a number of challenges which were not addressed by Microsoft.

My code and approach in this article will help you if you share any of the following motivations with me:

  • You are using a hierarchical DataSet to generate the tree for your dynamic content which would feed a sitemap.
  • You want to generate the XML needed for the ASP.NET SiteMap datasource straight from the DataSet in a fast way that minimizes lines of code.
  • Your content management system is large enough to vote against simply generating this DataSet on every web request (remember, it feeds breadcrumbs on all your pages) or filling up your server cache with this huge object that represents all the content in your system - thus you want a static XML SiteMap file that is updated every day (or at a frequency that you define).
  • You want to backup your XML SiteMap files for maintenance or just safety.
  • You have multiple content sections in your dynamic CMS that are different trees - meaning your pages are context sensitive and would feed off of separate sitemaps in the same web application.
  • You want to learn how to do an XSLT in memory in .NET 2.0.
  • You want to learn how to use embedded resource files in .NET 2.0.
  • You just want to see the XSLT that can turn a hierarchical DataSet into a *.sitemap file.
  • You want to see how to handle identical URLs in the sitemap (which is forbidden by the out-of-the-box SiteMapProvider).

Step 1: Identify your naming convention

To begin, you must define your naming convention for your XML file. As this is an automated process, you want to shield the client from thinking of its inner workings. In my case, I needed three components to uniquely identify my sitemaps:

  • ApplicationID (the ID for the web application of interest)
  • CultureName (the culture name for the current content, e.g., en-US or en-GB)
  • SitemapType (the content tree that uniquely identifies groups of content for separation)

I have the following fields in my Sitemap class:

#region Private Field Declarations
private Guid applicationId;
private string cultureName;
private DataSet dataSource;
private byte maxTiers;
private SitemapType type;
private string Id;
private string fileName;

//Can be modified for your data structure -
//    recommend repeating the construct for Tables.Count - 1
private object[,] heirarchicalRelations = new object[,] { { new string[] { "ID" }, 
        new string[] { "ParentID" } }, { new string[] { "ID" }, 
        new string[] { "ParentID" } }, { new string[] { "ID" }, 
        new string[] { "ParentID" } }, { new string[] { "ID" }, 
        new string[] { "ParentID" } } }; 
#endregion

The enumeration:

/// <summary>
/// This distinguishing property allows you to define multiple content areas within one
/// web application. Then you can call out the appropriate SiteMapProvider for different
/// path contexts in a single web application.
/// </summary>
public enum SitemapType
{
    /// <summary>
    /// A sitemap that lists all of the informational content in the system
    /// </summary>
    Content = 0,
    /// <summary>
    /// A sitemap that lists all of the properties and areas in the system
    /// </summary>
    Property = 1,
}

The constructor builds the ID and the file name (I don't need the application ID in the filename because the files are stored within directories in their own application anyway). We use Camel-cased naming conventions for private fields, and Pascal cased naming conventions for public properties in our software. I have omitted the public properties, but you can see below that they correspond to the private fields. ToString("G") formats to an enum's name.

/// <summary>
/// Generates a Sitemap from its Type, application ID, and requested culture
/// </summary>
/// <param name="appId">The unique ID for the application that the sitemap belongs to</param>
/// <param name="cName">Defines the culture that the client would like
///   to view the sitemap in. The application must support that culture.
///   The standard abbreviation string must be used.</param>
/// <param name="sType">The sitemap type that should be generated</param>
public CMSSitemap(Guid appId, string cName, SitemapType sType)
{
    this.ApplicationID = appId;
    this.CultureName = cName;
    this.Type = sType;
    StringBuilder pathBuilder = new StringBuilder(this.Type.ToString("G"));
    pathBuilder.Append("_");
    pathBuilder.Append(this.CultureName);
    pathBuilder.Append(".sitemap");

    this.FileName = pathBuilder.ToString();
    StringBuilder IdBuilder = 
       new StringBuilder(this.ApplicationID.ToString());
    IdBuilder.Append("_");
    IdBuilder.Append(this.Type.ToString("G"));
    IdBuilder.Append("_");
    IdBuilder.Append(this.CultureName);
    this.ID = IdBuilder.ToString();
    BSData helper = new BSData();
    SqlParameter p_applicationId = new SqlParameter("@ApplicationID", 
                 SqlDbType.UniqueIdentifier, 16, 
                 ParameterDirection.Input, false, ((Byte)(18)), 
                 ((Byte)(0)), "", DataRowVersion.Current, appId);
    SqlParameter p_cultureName = new SqlParameter("@CultureName", 
                 SqlDbType.VarChar, 10, ParameterDirection.Input, 
                 false, ((Byte)(18)), ((Byte)(0)), "", 
                 DataRowVersion.Current, cName);
    SqlParameter p_sitemapType = new SqlParameter("@SitemapType", 
                 SqlDbType.TinyInt, 3, ParameterDirection.Input, false, 
                 ((Byte)(18)), ((Byte)(0)), "", 
                 DataRowVersion.Current, sType);
    SqlParameter[] param = 
      new SqlParameter[3] { p_applicationId, p_cultureName, p_sitemapType };
    this.DataSource = helper.ReadOnlyHeirarchicalQuery(
                      helper.BWEnterpriseReader,
                      "dbo.[proc_getSitemapData]", 
                      this.heirarchicalRelations, param);
    this.modifyDuplicateUrls();
    this.MaxTiers = (byte)this.DataSource.Tables.Count;
}

Step 2: Take Care of Your Hierarchical Data

About our data layer (most likely you have your own methods, so populating your dataset is up to you). We use Stored Procedures exclusively to form the foundation of our Data Layer. They are called through ADO.NET helper methods where connections to the DB are isolated and encapsulated. They filter DataSets back to the business layer after building them. The method above, "ReadOnlyHeirarchicalQuery", will build a hierarchical DataSet with DataRelations.

The private field, heiarchicalRelations, is questionable right now. In order to allow the client to call that method and define multiple parent-child columns, I used an object[,] (you can have multiple-column PKs & FKs). This has the drawback of requiring the caller to know how many tiers will come back to form the DataRelations. If I were to restrict the DataSet to have Parent and Children ID columns named the same exclusively, I could have a more confined but elegant solution, because I could generate the DataRelations on the fly based on how many DataTables came back from SQL. In this new scenario, the caller does not need to know how many DataRelations must be built. I will do this before release because our tiers are fluctuating now.

I know that you can manage your own hierarchical DataSets with your own methods, but I have provided this information to you as a proof of concept for my work.

Useful fact from a Microsoft conference - I did not give you the real name of my sproc up there, but notice that I prefix it with proc_. You should not prefix your sprocs with sp_ because when you do so, you slow down your entire data layer because SQL will search all of the system Stored Procedures before finding your sproc.

The method I call in the constructor is shown below for your reference:

/// <summary>
/// Allows for execution of multiple-select statements with one sproc,
/// and heirarchical datasets with auto-creation of relations
/// </summary>
/// <param name="comPathString">Connection string to the DB</param>
/// <param name="sprocName">Name of sproc to execute</param>
/// <param name="dataRelations">Each object must be a string[],
/// with column 0 of the multi-dimensional object[] being the string[]
/// of Parent Column Names, and column 1 being the string[] of Child Column Names.
/// This construct is required to support multi-column PK and FKs</param>
/// <param name="paramList">Parameter array for the stored procedure</param>
/// <returns>Heirarchical DataSet</returns>
internal DataSet ReadOnlyHeirarchicalQuery(string comPathString, 
         string sprocName, object[,] dataRelations, SqlParameter[] paramList)
{
    SqlConnection comPath = new SqlConnection(comPathString);
    SqlCommand executeSproc = new SqlCommand(sprocName,comPath);
    executeSproc.CommandType = CommandType.StoredProcedure;
    foreach (SqlParameter p in paramList)
    {
        executeSproc.Parameters.Add(p);
    }
    DataSet finalResultSet = new DataSet("finalResultsDS");

    try
    {
        comPath.Open();
        SqlDataReader readSprocResults = executeSproc.ExecuteReader();

        do
        {
            finalResultSet.Tables.Add();
            foreach(DataRow r in readSprocResults.GetSchemaTable().Rows)
            {
                finalResultSet.Tables[finalResultSet.Tables.Count-1].Columns.Add(
                               r[0].ToString(),Type.GetType(r[12].ToString()));
            }
            while (readSprocResults.Read())
            {
                addRow = finalResultSet.Tables[finalResultSet.Tables.Count-1].NewRow();
                foreach (DataColumn c in 
                         finalResultSet.Tables[finalResultSet.Tables.Count-1].Columns)
                {
                    addRow[c.Ordinal] = readSprocResults[c.Ordinal]; 
                }
                finalResultSet.Tables[finalResultSet.Tables.Count-1].Rows.Add(addRow);
            }
        }
        while (readSprocResults.NextResult());

        comPath.Close();

        for (int i=0;i<=dataRelations.GetUpperBound(0);i++)
        {
            string[] parents = (string[])dataRelations[i,0];
            string[] children = (string[])dataRelations[i,1];
            DataColumn[] pc = new DataColumn[parents.Length];
            DataColumn[] cc = new DataColumn[parents.Length];
            for (int j=0;j<parents.Length;j++)
            {
                pc[j] = finalResultSet.Tables[i].Columns[parents[j]];
            }
            for (int j=0;j<children.Length;j++)
            {
                cc[j] = finalResultSet.Tables[i+1].Columns[children[j]];
            }
            DataRelation tempDR = new DataRelation("", pc, cc);
            tempDR.Nested = true;
            finalResultSet.Relations.Add(tempDR);
        }
    }
    catch (Exception e)
    {
        comPath.Close();
        throw new ApplicationException(e.Message);
    }
    return finalResultSet;
}

The only thing to note here if you are handling your own DataSet - the name of the DataSet is finalResultsDS. Remember that for the XSLT.

Step 3: Handle duplicate URLs

One last step to initialize the data of your class - this.modifyDuplicateUrls();.

I have a problem in my site hierarchy - the business users want to be able to categorize content in multiple places at times. This feeds the menu system as well. You can handle this another way - when you generate the DataSet - only choose one path for the content. In my case, this was not an option. The users want to see the content show up in the hierarchy that they defined.

Why does this pose as a problem? Think about what the SiteMap classes in the .NET framework must do to generate breadcrumbs - the HttpRequest defines a URL to hit, and now your SiteMapProvider has to figure out, well, "Where in the hierarchy does this URL correspond to." If there is more than one option, how does it know which path you meant? And then, how does it generate breadcrumbs when your paths are not mutually exclusive? Exactly. It can't. You would need to write your own SiteMapProvider which has logic that makes decisions in that instance. I don't have time for that. I just wanted to keep the SiteMap Data Hierarchy's integrity, as defined by the business users, but still use the built-in provider. I did not want to forgo the optimizations and the fine code given to me by Microsoft's built-in provider. I simply did not see the need to invest that much time.

I achieved my goal by tricking it with a QueryString parameter. I keep the original URL the first time it is seen in the hierarchy. For duplicates, I add a dummy QueryString parameter that can be used to distinguish them. I do the same for my menu system hierarchy so that the breadcrumbs match the menus. Yet still, if a user externally linked one of our pages, my CMS can resolve the content they want to get, without a problem, by providing a fallback. That is out of the scope of this article, and begins to get into my custom CMS which is a very powerful, multi-lingual system. All you need to know is that this method will allow you to trick the default SiteMapProvider into behaving how I needed it for my requirements. The following method does the trick:

/// <summary>
/// SiteMap datasources cannot have duplicate Urls with the default provider.
/// This finds duplicate urls in your heirarchy
/// and tricks the provider into treating them correctly
/// </summary>
private void modifyDuplicateUrls()
{
    StringCollection urls = new StringCollection();
    string rowUrl = String.Empty;
    uint duplicateCounter = 0;
    string urlModifier = String.Empty;
    foreach (DataTable dt in this.DataSource.Tables)
    {
        foreach (DataRow dr in dt.Rows)
        {
            rowUrl = (string)dr["Url"];
            if (urls.Contains(rowUrl))
            {
                duplicateCounter++;
                if (rowUrl.Contains("?"))
                {
                    urlModifier = "&instance=" + duplicateCounter.ToString();
                }
                else
                {
                    urlModifier = "?instance=" + duplicateCounter.ToString();
                }
                dr["Url"] = rowUrl + urlModifier;
            }
            else
            {
                urls.Add(rowUrl);
            }
        } 
    }
}

Step 4: Write your XSLT and embed it in your Class Library

You need an XSLT to get your *.sitemap files into the right format straight from your DataSet. I have shown below an example DataSet.GetXML() output, the XSLT to transform it, and the resulting *.sitemap file. I have only included truncated snippets of the input and output for brevity. If you have a different naming convention for your DataSets and DataTables, you will have to modify the XSLT accordingly. These files are included in the source code download.

The input XML generated from DataSet.GetXML():

<FINALRESULTSDS>
 <TABLE1>
  <ID>3efae161-e807-4419-98d5-162f69cca7da</ID>
  
  <DESCRIPTION>Find and reserve corporate housing and serviced 
     apartments anywhere in the world. - Find and Reserve</DESCRIPTION>
  <URL>~/Apps/AdvancedPropertySearch.aspx?CM=2441358F-1E9D-4694-8E82-9E4FB4E6AD16</URL>
  <ITEMORDER>0</ITEMORDER>
 <TABLE2>
  <ID>8115e4ef-153b-4b11-85bd-5e0a10d16c59</ID>
  <PARENTID>3efae161-e807-4419-98d5-162f69cca7da</PARENTID>
  
  <DESCRIPTION>Reservation Request</DESCRIPTION>
  <URL>~/Apps/ReservationRequest.aspx</URL>
  <ITEMORDER>0</ITEMORDER>
  </TABLE2>
  </TABLE1>
 <TABLE1>
  <ID>4d60e90d-8ede-4e52-88c9-65b9416ff02a</ID>
  
  <DESCRIPTION>Temporary housing for extended stays by BridgeStreet 
    Worldwide corporate housing and serviced 
    apartments. - Accommodations Solutions</DESCRIPTION>
  <URL>~/Apps/CMSTemplate.aspx?CM=4B042082-E8CB-4A34-A589-936F3642F7A1</URL>
  <ITEMORDER>1</ITEMORDER>
 <TABLE2>
  <ID>6e49589e-3743-4ded-a718-41a615a5c4f0</ID>
  <PARENTID>4d60e90d-8ede-4e52-88c9-65b9416ff02a</PARENTID>
  
  <DESCRIPTION>Temporary housing for extended stays and business travel 
    by BridgeStreet Worldwide corporate housing 
    and serviced apartments. - Business Travelers</DESCRIPTION>
  <URL>~/Apps/CMSTemplate.aspx?CM=64DA181A-7A9E-4811-AC0D-7A0C89827F28</URL>
  <ITEMORDER>0</ITEMORDER>
  </TABLE2>
 <TABLE2>
  <ID>ce5614cb-c8b4-449c-a494-8c91b3bb3328</ID>
  <PARENTID>4d60e90d-8ede-4e52-88c9-65b9416ff02a</PARENTID>
  
  <DESCRIPTION>Extended stay accommodations for military personnel 
    and government travelers through BridgeStreet Worldwide corporate 
    housing and serviced apartments. - Government Travelers</DESCRIPTION>
  <URL>~/Apps/CMSTemplate.aspx?CM=6EF08802-4023-4F81-8C1E-1C3D9B4CEA86</URL>
  <ITEMORDER>1</ITEMORDER>
  </TABLE2>

XSLT. You can see that it supports six levels deep for your hierarchy. You can add more if you need to. Or, I'm sure there's a better way to do this with recursion, but my strength is not XSLT, so if someone out there can show a more elegant way to perform this, which does not require a limited level of nesting, please chime in on the comments. In the meantime, this will do the job, and it does it well:

<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<xsl:apply-templates /> 
</xsl:template>
<xsl:template match="/finalResultsDS">
<xsl:element name="siteMap" 
  namespace="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0">
<!-- First Table in Heirarchy -->
<xsl:for-each select="./*[starts-with(local-name(), 'Table')]">
<xsl:element name="siteMapNode" 
  namespace="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0">
<xsl:call-template name="transformElementsToAttributes" />
<!-- Second Table in Heirarchy -->
<xsl:for-each select="./*[starts-with(local-name(), 'Table')]">
<xsl:element name="siteMapNode" 
  namespace="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0">
<xsl:call-template name="transformElementsToAttributes" />
<!-- Third Table in Heirarchy -->
<xsl:for-each select="./*[starts-with(local-name(), 'Table')]">
<xsl:element name="siteMapNode" 
  namespace="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0">
<xsl:call-template name="transformElementsToAttributes" />
<!-- Fourth Table in Heirarchy -->
<xsl:for-each select="./*[starts-with(local-name(), 'Table')]">
<xsl:element name="siteMapNode" 
  namespace="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0">
<xsl:call-template name="transformElementsToAttributes" />
<!-- Fifth Table in Heirarchy -->
<xsl:for-each select="./*[starts-with(local-name(), 'Table')]">
<xsl:element name="siteMapNode" 
  namespace="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0">
<xsl:call-template name="transformElementsToAttributes" />
<!-- Sixth Table in Heirarchy -->
<xsl:for-each select="./*[starts-with(local-name(), 'Table')]">
<xsl:element name="siteMapNode" 
   namespace="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0">
<xsl:call-template name="transformElementsToAttributes" />
</xsl:element>
</xsl:for-each>
</xsl:element>
</xsl:for-each>
</xsl:element>
</xsl:for-each>
</xsl:element>
</xsl:for-each>
</xsl:element>
</xsl:for-each>
</xsl:element>
</xsl:for-each>
</xsl:element>
</xsl:template>
<xsl:template name="transformElementsToAttributes">
<xsl:attribute name="url">
<xsl:value-of select="Url"/>
</xsl:attribute>
<xsl:attribute name="title">
<xsl:value-of select="Title"/>
</xsl:attribute>
<xsl:attribute name="description">
<xsl:value-of select="Description"/>
</xsl:attribute>
</xsl:template>
</xsl:stylesheet>

Oh la la, I have shown below the French Sitemap that is the output from my fun class. This is the end product:

<?xml version="1.0" encoding="utf-8"?>
<siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0">
<siteMapNode url="/Default.aspx" title="Accueil" description="Accueil">
<siteMapNode url="~/Apps/AdvancedPropertySearch.aspx?CM=00CB5839-329F-4692-9A60-2D4A389DD6DB" 
   title="Rechercher et R‚server" description="Solutions d'h‚
          bergement en appartements meubl‚s avec services h“teliers 
          pour tous types de s‚jour : courts s‚jours, s‚jours professionnels, 
          d‚menagement, relocation - Rechercher et R‚server">
<siteMapNode url="~/Apps/ReservationRequest.aspx" 
        title="Demande de R‚servation" description="Demande de R‚servation" />
</siteMapNode>
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=1EBB6235-4174-437C-B23F-31B9E58E4FC0" 
  title="Solutions de Logement" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec services 
               h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, relocation - Solutions de Logement">
.
.
.
.
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=40ECF480-0ECA-4121-AB28-E1CC886E419E" 
  title="Achat / Vente" 
  description="Propositions d'appartements … vendre sur Paris - Achat / Vente" />
</siteMapNode>
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=3093CA21-5CB4-4EDF-9C46-F0E6C8ED922F" 
  title="Pourquoi des R‚sidences H“teliŠres" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec services 
               h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, 
               relocation - Pourquoi des R‚sidences H“teliŠres ?">
<siteMapNode url="~/Apps/FAQ.aspx" title="FAQ" description="FAQ" />
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=0F626C88-6ABC-4B7F-8F71-5055184F7030" 
  title="Am‚nagements & Services" 
  description="Solutions d'h‚bergement 
               en appartements meubl‚s avec services h“teliers pour tous 
               types de s‚jour : courts s‚jours, s‚jours professionnels, 
               d‚menagement, relocation - Am‚nagements & Services" />
.
.
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=8A2B0A11-74AD-4551-96EF-C13428E3E227" 
  title="R“le Dans le Secteur" description="Solutions d'h‚bergement en appartements 
         meubl‚s avec services h“teliers pour tous types de s‚jour : courts s‚jours, 
         s‚jours professionnels, d‚menagement, relocation - R“le Dans le Secteur" />
</siteMapNode>
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=25D55721-C606-4C42-8F98-7EDBABABA819" 
  title="Qui Sommes-nous" 
  description="Solutions d'h‚bergement en appartements 
               meubl‚s avec services h“teliers pour tous types de s‚jour : courts s‚
               jours, s‚jours professionnels, d‚menagement, relocation - Qui Sommes-nous">
.
.
.
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=3237E7F6-F732-4F24-9E65-C761B0AE8800" 
  title="CarriŠres" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec services 
               h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, relocation - CarriŠres" />
</siteMapNode>
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=885399BD-44A4-4720-8C75-F6521ED3A2D4" 
      title="Partenariat Mondial" 
      description="Solutions d'h‚bergement en appartements meubl‚s avec 
                   services h“teliers pour tous types de s‚jour : courts s‚jours, 
                   s‚jours professionnels, d‚menagement, relocation - Partenariat Mondial">
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=2E1B9DAA-A2AC-48CD-8716-2EF113E71973" 
      title="BridgeNet Login" 
      description="Solutions d'h‚bergement en appartements meubl‚s avec 
                   services h“teliers pour tous types de s‚jour : courts s‚jours, 
                   s‚jours professionnels, d‚menagement, 
                   relocation - Bienvenue … Nos Partenaires Mondiaux" />
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=1B27D0CC-53C5-4A78-A5E0-BD4AA8A51C94" 
      title="BridgeStreet Global Alliance" 
      description="Solutions d'h‚bergement en appartements meubl‚s avec 
                   services h“teliers pour tous types de s‚jour : courts s‚jours, 
                   s‚jours professionnels, d‚menagement, 
                   relocation - BridgeStreet Global Alliance" />
</siteMapNode>
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=ADAF636D-0481-4ECC-B918-4BBF1D11FD4E" 
  title="Des Solutions Faciles Pour L’h‚bergement D’affaires" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec services 
               h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, 
               relocation - Des Solutions Faciles Pour L’h‚bergement D’affaires">
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=5295303A-1EEA-4FF5-A913-2842540F0762" 
  title="Programmes Tech" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec services 
               h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, relocation - Programmes Tech" />
</siteMapNode>
<siteMapNode url="~/Apps/Announcements.aspx?CM=50C83AAB-76F1-4BB9-88BA-720A116286BA" 
  title="Actualit‚ de L’entreprise" 
  description="Temporary housing for extended stays by BridgeStreet 
               Worldwide corporate housing and serviced 
               apartments. - Corporate News - September 7, 2006">
.
.
.
.
<siteMapNode 
  url="~/Apps/ClientNewsletter.aspx?CM=40753C0C-C80A-4FA9-B926-DC077C30EAD9" 
  title="Q1 2006 Client Newsletter" 
  description="Q1 2006 Client Newsletter - Client Newsletter - Q1 2006" />
</siteMapNode>
<siteMapNode url="~/Apps/Announcements.aspx?CM=D596B11F-2B3C-A4E6-6126-05FAD803B16D" 
  title="BridgeStreet Worldwide's Global Partners Expand Company's West Coast Presence" 
  description="BridgeStreet Worldwide's Global Partners Expand Company's West 
               Coast Presence - Company Enters Pacific Northwest 
               and San Francisco Markets - Corporate News - November 16, 2005" />
<siteMapNode url="~/Apps/Announcements.aspx?CM=D596B17D-2B3C-A4E6-6098-06F29E5EF2F3" 
  title="BridgeStreet Worldwide Receives 2005 Technology ROI Award" 
  description="BridgeStreet Worldwide Receives 2005 Technology ROI Award - Winners 
               Use Technology Solutions to Achieve Positive Business 
               and Financial Results - Corporate News - August 17, 2005" />
.
.
.
.
<siteMapNode url="~/Apps/Announcements.aspx?CM=D596B390-2B3C-A4E6-6400-FA0765B40496" 
  title="BridgeStreet Is First to Place Full Corporate Housing Inventory on GDS" 
  description="BridgeStreet Is First to Place Full Corporate Housing Inventory 
               on GDS - Online Access Opens Up New Commission Opportunities 
               for Travel Agents - Corporate News - June 7, 2002" />
<siteMapNode url="~/Apps/Announcements.aspx?CM=D596B13F-2B3C-A4E6-6D47-FCB993A8BD49" 
  title="BridgeStreet Worldwide Chicago Office Receives Record-Setting Fourth 
         Consecutive “CAMME” Award from Chicagoland Apartment Association" 
  description="BridgeStreet Worldwide Chicago Office Receives Record-Setting 
               Fourth Consecutive “CAMME” Award from Chicagoland 
               Apartment Association - Company Also Wins “Associate 
               Member Brochure—Print Form” Category - Corporate News - October 20, 2005" />
</siteMapNode>
<siteMapNode 
  url="~/Apps/ContactUs.aspx?CM=4F38AB79-0BFE-4DB5-9C19-1D056C287808&instance=3" 
  title="Nous Contacter" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec 
               services h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, relocation - Nous Contacter" />
</siteMapNode>
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=690CC898-F538-4772-A127-CCBE5FB2742B" 
  title="Mon Compte" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec 
               services h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, relocation - Mon Compte">
.
.
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=0612F0E6-B3E1-43A1-8EBC-7CB36EC70D06" 
  title="tudes de Cas" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec 
               services h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, relocation - tudes de Cas" />
</siteMapNode>
<siteMapNode url="~/Apps/ContactUs.aspx?CM=4F38AB79-0BFE-4DB5-9C19-1D056C287808" 
  title="Nous Contacter" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec 
               services h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, relocation - Nous Contacter" />
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=077BF590-4317-431A-8E25-14134B86C6E4" 
  title="Politique de Confidentialit‚" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec 
               services h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, 
               relocation - Politique de Confidentialit‚" />
</siteMapNode>
<siteMapNode url="~/Apps/WebSpecials.aspx?CM=00D1CBCB-48B2-4033-86F4-258EA017997F" 
  title="Informations Sp‚ciales" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec services 
               h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, 
               relocation - Informations Sp‚ciales">
<siteMapNode url="~/Apps/WebSpecials.aspx?CM=E255629F-6C1A-4DA3-BAD7-03E3525B4EBE" 
  title="Pittsburgh, Pennsylvania" 
  description="Temporary housing for extended stays by BridgeStreet 
               Worldwide corporate housing and serviced apartments. - Area Special" />
.
.
.
.
<siteMapNode url="~/Apps/WebSpecials.aspx?CM=B4B978F7-DEC2-47AA-BEAA-F64BB70096F5" 
  title="St. Louis, Missouri" 
  description="Temporary housing for extended stays by BridgeStreet 
               Worldwide corporate housing and serviced apartments. - Area Special" />
</siteMapNode>
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=A432E443-F244-47AD-B074-D401F72B9A64" 
  title="Flash Info" 
  description="Solutions d'h‚bergement en appartements meubl‚s avec services 
               h“teliers pour tous types de s‚jour : courts s‚jours, 
               s‚jours professionnels, d‚menagement, 
               relocation - Flash Info - Travail dur... Sommeil facile.">
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=3EC5E979-3916-43B0-B935-40895D8CBDA1" 
    title="Saint Germain" description="Solutions d'h‚bergement en appartements meubl‚s avec 
           services h“teliers pour tous types de s‚jour : courts s‚jours, 
           s‚jours professionnels, d‚menagement, relocation - Flash Info - Saint Germain" />
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=C4B7E9ED-0E8C-494E-9BDF-5BACDE299E47" 
    title="Bridgestreet l'Opera" 
    description="Solutions d'h‚bergement en appartements meubl‚s avec services 
                 h“teliers pour tous types de s‚jour : courts s‚jours, 
                 s‚jours professionnels, d‚menagement, 
                 relocation - Flash Info - Bridgestreet l'Opera" />
.
.
.
.
<siteMapNode url="~/Apps/CMSTemplate.aspx?CM=0E7F3F43-4416-491D-9A0C-D968D7E101B6" 
  title="Notting Hill" 
  description="Solutions d'h�bergement en appartements meubl�s avec services 
               h�teliers pour tous types de s�jour : courts s�jours, 
               s�jours professionnels, d�menagement, 
               relocation - Flash Info - Notting Hill" />
</siteMapNode>
</siteMapNode>
</siteMap>

Now, all you have to do is take your XSLT and embed it in your DLL. Add a new XSLT file to your class library project. Simply copy the XSLT code from above (or the downloaded source code). Once it's in your project, you can add it as a resource file to be extracted from the assembly as a byte[] (can we say, no extraneous disk reads?).

On the class library property pages, select the “Resources” tab. Add your file to the resources. Select the newly added XSLT file. Set the properties as such (FileType must be Binary):

Resource File Type Setting

Go to the file in the Solution Explorer, select the file you need to embed, and change the Build Action to Embedded Resource:

Build Action Setting

Now, when your class library builds, you can access the file in memory without using System.Reflection like in .NET 1.1.

Step 5: Backup and Write methods

Now you need to write the code that will backup existing files, perform the transformation, and write the *.sitemap file.

First the backup method. I only wanted a daily backup, so I named my files accordingly. You can change the ToString("") date format info if you want a different scheme.

/// <summary>
/// Backs up the current Sitemap file to a user defined directory
/// </summary>
/// <param name="pathToCurrent">The relative path to the directory
///   where the current files are stored</param>
/// <param name="pathToBackup">The relative path
///   to the directory where the sitemap backup files are stored</param>
public void BackupCurrentFile(string pathToCurrent, string pathToBackup)
{
    string fullCurrentPath = HttpContext.Current.Server.MapPath(pathToCurrent);
    string fullBackupPath = HttpContext.Current.Server.MapPath(pathToBackup);
    if (Path.HasExtension(fullCurrentPath) || Path.HasExtension(fullBackupPath))
    {
    throw new ArgumentException("You cannot specify the fileName " + 
              "of your sitemap, please provide only the directories " + 
              "where you'd like to store your sitemap files.",
              "pathToCurrent or pathToBackup");
    }
    else
    {
        if (!fullCurrentPath.EndsWith("\\"))
        {
            fullCurrentPath = fullCurrentPath + "\\";
        }
        if (!fullBackupPath.EndsWith("\\"))
        {
            fullBackupPath = fullBackupPath + "\\";
        }
        fullCurrentPath = fullCurrentPath + this.FileName;
        string backupFileName = 
          Path.GetFileNameWithoutExtension(fullBackupPath + this.FileName);
        backupFileName = backupFileName + "_" + 
                         DateTime.Now.ToString("yyyy-MM-dd") + ".sitemap";
        fullBackupPath = fullBackupPath + backupFileName;
        FileInfo siteMapFileInfo = new FileInfo(fullCurrentPath);
        FileInfo backupFileInfo = new FileInfo(fullBackupPath);
        if (backupFileInfo.Exists)
        {
            File.SetAttributes(fullBackupPath, FileAttributes.Normal);
        }
        siteMapFileInfo.CopyTo(fullBackupPath, true); 
    }
}

And now, the moment you've been waiting for, the transformation and file write. You can see that the XSLT is pulled out of memory as a byte[]. This is a smokin' fast technique with minimal code. Note that SiteMapTransformer is the name of the embedded resource (the XSLT file). Also note that the DataSet.GetXML() method generates the preceding input schema right from the dataset.

/// <summary>
/// Writes the Sitemap to a standard ASP.NET sitemap xml file
/// </summary>
/// <param name="fileName">The relative path name where you want to write to</param>
/// <param name="pathToCurrent">The relative path to the directory
///           where the current sitemap files are stored</param>
/// <param name="pathToBackup">The relative path to the directory
///         where the sitemap backup files are stored</param>
public void WriteFile(string pathToCurrent, string pathToBackup)
{ 
    string fullPath = HttpContext.Current.Server.MapPath(pathToCurrent);

    if (Path.HasExtension(fullPath))
    {
        throw new ArgumentException("You cannot specify the fileName " + 
              "of your sitemap, please provide only the directory " + 
              "you'd like to store your sitemap files.",
              "pathToCurrent");
    }
    else
    {
        if (!fullPath.EndsWith("\\"))
        {
            fullPath = fullPath + "\\";
        }
        FileInfo siteMapFileInfo = new FileInfo(fullPath + this.FileName);

        //Backup the current file
        if (siteMapFileInfo.Exists)
        {
            this.BackupCurrentFile(pathToCurrent, pathToBackup);
        }
        XslCompiledTransform xslt = new XslCompiledTransform(); 
        MemoryStream transformStream = new MemoryStream(Resources.SiteMapTransformer); 
        MemoryStream siteMapXmlStream = new MemoryStream();
        //Generate Xml from the heirarchical dataset
        XmlReader dataSetXmlReader = 
          XmlReader.Create(new StringReader(this.DataSource.GetXml()));
        //Load the Xslt file from the assembly manifest (now in a stream)
        xslt.Load(XmlReader.Create(transformStream)); 

        //Perform the transformation 
        xslt.Transform(dataSetXmlReader, XmlWriter.Create(siteMapXmlStream));
        //Write the stream to file, the current file is overwritten if it exists
        if (siteMapFileInfo.Exists)
        {
            File.SetAttributes(siteMapFileInfo.FullName, FileAttributes.Normal);
        }
        File.WriteAllBytes(siteMapFileInfo.FullName, siteMapXmlStream.ToArray());
    }
}

Step 6: Use it

We had to define different sitemap providers in our web.config based on the different types of sitemaps we had. Here are the Web.Config requirements:

<siteMap defaultProvider="Content_en-US" enabled="true">
<providers>
<clear/>
<add name="Content_en-US" description="The SiteMap Provider for en-US Content"
  type="System.Web.XmlSiteMapProvider, System.Web, Version=2.0.3600.0, 
        Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
  siteMapFile="/Website/SiteMaps/Content_en-US.sitemap" />
<add name="Content_en-GB"
  description="The SiteMap Provider for en-GB Content"
  type="System.Web.XmlSiteMapProvider, System.Web, Version=2.0.3600.0, 
        Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
  siteMapFile="/Website/SiteMaps/Content_en-GB.sitemap" />
<add name="Content_fr-FR"
  description="The SiteMap Provider for fr-FR Content"
  type="System.Web.XmlSiteMapProvider, System.Web, 
        Version=2.0.3600.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
  siteMapFile="/Website/SiteMaps/Content_fr-FR.sitemap" />
<add name="Properties_en-US"
  description="The SiteMap Provider for en-US Properties"
  type="System.Web.XmlSiteMapProvider, System.Web, 
        Version=2.0.3600.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
  siteMapFile="/Website/SiteMaps/Properties_en-US.sitemap" />
<add name="Properties_en-GB"
  description="The SiteMap Provider for en-GB Properties"
  type="System.Web.XmlSiteMapProvider, System.Web, 
        Version=2.0.3600.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
  siteMapFile="/Website/SiteMaps/Content_en-GB.sitemap" />
<add name="Properties_fr-FR"
  description="The SiteMap Provider for fr-FR Properties"
  type="System.Web.XmlSiteMapProvider, System.Web, 
        Version=2.0.3600.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
  siteMapFile="/Website/SiteMaps/Properties_fr-FR.sitemap" />
</providers>
</siteMap>

Now your front end code is really simple:

<asp:Label ID="SC_Title" runat="server" SkinID="MainContentHeader" 
           meta:resourcekey="SC_TitleResource1"></asp:Label><br />
<br />
<asp:Label ID="SC_ContentSitemapLabel" SkinID="MainContentSubHeader" 
  runat="server" meta:resourcekey="SC_ContentSitemapLabelResource1"></asp:Label><br /><br />
<asp:TreeView SkinID="SiteMapTree" ID="SC_ContentTree" 
  DataSourceID="SC_SiteMapSource" runat="server" meta:resourcekey="SC_ContentTreeResource1">
</asp:TreeView><br /><br />
<asp:Label ID="SC_PropertySitemapLabel" SkinID="MainContentSubHeader" 
  runat="server" meta:resourcekey="SC_PropertySitemapLabelResource1"></asp:Label><br /><br />
<asp:SiteMapDataSource ID="SC_SiteMapSource" runat="server" />

Code-behind example on page_load (ExpandDepth is how many tiers you want the initial view to be expanded to):

string currentCultureName = Session["CurrentCulture"] as string;
this.SC_SiteMapSource.SiteMapProvider = "Content_" + currentCultureName;
this.SC_ContentTree.ExpandDepth = 1;

Now you have your sitemap, but what about breadcrumbs? Here you go:

<asp:SiteMapPath SkinID="SiteMapPath" ID="SC_SiteMapPath" runat="server">
</asp:SiteMapPath>

On the Master Page:

public void SetSiteMapProvider(string siteMapProviderName)
{
    this.SC_SiteMapPath.SiteMapProvider = siteMapProviderName;
}

On the Web Form:

this.Master.SetSiteMapProvider("Content_" + currentCulture);

You get the idea. Consuming the *.sitemap file is quite trivial.

Step 7: Scheduling your SiteMap file generation

This is where one size does not fit all. You have the class with methods that can do the job now. There are three approaches I can recommend to scheduling the sitemap file write and backup: Windows Service, a WinForms App running on a scheduled task server, or in .NET 3.0, there are some cool things in the WWF (Workflow Foundation, not wrestling). Until MS is RTM for 3.0, I would stick with a Windows Service. That is outside the scope of this article, but the code that does the job is also quite trivial. You just get a handle on the HttpContext for the website of interest, and then use the class as such:

CMSSitemap sitemap = new CMSSitemap(new Guid("YOURAPPGUID"),
"fr-FR", SitemapType.Content);
sitemap.WriteFile("/Website/SiteMaps", "/Website/SiteMapBackup");

The ideal solution which you're probably screaming at me right now is to trigger this code block when you know for sure that content has been deleted or inserted into your Dynamic CMS. If you have power over this, do it! Remember, if you have a CMS that is this big, and one of your primary goals is to index your content on search engines, you want the SiteMap to reflect the content that is most up to date for both browsing and search engine indexing purposes. If you're using the ASP.NET TreeView control, feel safe that Lynx can see it. Lynx could not see our links in an Infragistics TreeMenu control. Whatever controls you're using for your Presentation Layer, be sure that a text-based browser like Lynx can see the links if you're concerned about search engine indexing.

Also note that there's still some fuzziness surrounding whether search engines will index query string URLs. I see that Google tries to index mine in my Google webmaster account. You may want to be sure of this and put some effort into it. If you need to take it a step further, try search engine safe (SES) URLs. This guy did a fantastic job on an HttpModule for that: http://weblogs.asp.net/skillet/archive/2004/05/04/125523.aspx.

Final comments

You achieved a lot with your implementation. Having a sitemap for a dynamic system is not a small task. ASP.NET 2.0 takes the edge off the "VIEW" code. You don't want to rip through your CMS database for each webrequest, but you want to take advantage of the built-in .NET 2.0 SiteMap controls and providers. Your SiteMap XMLs do a lot; they branch your content into different sitemap types which can be context sensitive, can support multiple languages, and shine your shoes while they're at it. The XSLT takes your dataset straight to *.sitemap XML format in a few lines of code. Embedding it in the class library as a byte[] and consuming it as a MemoryStream is way cool.

You saw the breadcrumbs up at the top of the article. Here is a view of the sitemap tree:

Sample screenshot

You can see that our CMS has defined content types that we would like to isolate for usability. Having multiple sitemap types allows us to feed off of this excellent architectural feature.

You can see this and more on http://www.bridgestreet.com/Apps/Sitemap.aspx. Non-relational, flat UI data is multilingual via satellite assemblies. Relational data that runs the menus and content is cached in server memory as custom objects, which makes the site incredibly fast because not a single element is static on any page, yet very few database trips ever have to be made. My other geographic article is implemented on that site too. You can see its power on the website.

I hope that you can use these concepts and adapt them to your advantage in your own Sitemap endeavors.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here