LibrarySites.Banner

YALP: Yet Another Link Provider for the Sitecore ASP.NET CMS

This blog post provides an untested prototype for yet another link provider for the Sitecore ASP.NET web Content Management System (CMS) and Experience Platform (XP). This implementation provides the following features beyond those provided by Sitecore itself:

  • Applies the Rendering.SiteResolving setting specified in the Web.config file.
  • Allows attributes of the context site to override attributes of the link provider, including attributes that affect whether and how Sitecore presents languages in URLs.
  • Allows appending slashes to URLs.
  • Allows aliases to override URLs, using a search index for performance.

Additionally, this blog post demonstrates how to use custom UrlOptions, which allow extension not only of how Sitecore determines URL options, but of the very URL options that it can apply. It also shows how to override the LinkBuilder that actually constructs URLs.

First, some terminology:

  • LinkManager: Your code should call this class when it needs to generate links. This class hides the details of link provision.
  • UrlOptions: This class exposes properties
  • LinkProvider: This class is the default link provider, which reads its configuration from Web.config. The CreateUrlOptions() class of this provider creates UrlOptions instances based on those values.
  • LinkBuilder: This is a class internal to LinkProvider that actually constructs URLs using UrlOptions passed from LinkProvider.

The LinkProvider override described in this blog post supports the following attributes (custom attributes in bold). Except for siteResolving, which you can only apply to a site definition, you can use each of these attributes both in the provider definition and in site definitions. Except for those with a list of values, each of these is a Boolean.

  • applyAliases
  • appendSlashes
  • siteResolving (only at site level, not at provider level)
  • addAspxExtension
  • alwaysIncludeServerUrl
  • encodeNames
  • lowercaseUrls
  • useDisplayName
  • shortenUrls
  • languageEmbedding: always, never, or asNeeded.
  • languageLocation: filePath or queryString.

An override of UrlOptions exposes two new properties, ApplyAliases and AppendSlashes.

namespace SitecoreJohn.Links
{
  using System.Reflection;
 
  using Sitecore.Diagnostics;
  using Sitecore.Reflection;
 
  public class UrlOptions : Sitecore.Links.UrlOptions
  {
    public UrlOptions(Sitecore.Links.UrlOptions baseOptions)
    {
      Assert.ArgumentNotNull(baseOptions, "baseOptions)");
 
      foreach (PropertyInfo property in baseOptions.GetType().GetProperties())
      {
        if (ReflectionUtil.GetProperty(
          this,
          property.Name) != null)
        {
          ReflectionUtil.SetProperty(
            this,
            property.Name,
            property.GetValue(baseOptions));
        }
      }
    }
 
    public bool AppendSlashes { get; set; }
 
    public bool ApplyAliases { get; set; }
  }
}

We need a computed index field to store the ID of items associated with aliases. Note how this moves almost all of the work from runtime to index time, such as checking base templates. At runtime (in content delivery), if the index has a value in this field, the record is an alias.

namespace SitecoreJohn.ContentSearch.ComputedFields
{
  using System.Linq;
 
  using Sitecore;
  using Sitecore.Configuration;
  using Sitecore.ContentSearch;
  using Sitecore.Data.Fields;
  using Sitecore.Data.Items;
  using Sitecore.Diagnostics;
 
  public class AliasTarget : Sitecore.ContentSearch.ComputedFields.IComputedIndexField
  {
    public string FieldName { get; set; }
  
    public string ReturnType { get; set; }
  
    public object ComputeFieldValue(Sitecore.ContentSearch.IIndexable indexable)
    {
      Assert.ArgumentNotNull(indexable, "indexable");
      SitecoreIndexableItem scIndexable =
        indexable as Sitecore.ContentSearch.SitecoreIndexableItem;
 
      if (!Settings.AliasesActive)
      {
        Log.Warn("AliasesActive false but " + this + " enabled", this);
        return false;
      }
  
      if (scIndexable == null)
      {
        Log.Warn(
          this + " : unsupported IIndexable type : " + indexable.GetType(),
          this);
        return false;
      }
  
      Item item = (Item)scIndexable;
  
      if (item == null)
      {
        Log.Warn(
          this + " : unsupported SitecoreIndexableItem type : " + scIndexable.GetType(),
          this);
        return false;
      }
 
      // optimization to reduce indexing time
      // by skipping this logic for items in the Core database
      if (string.Compare(
        item.Database.Name,
        "core",
        System.StringComparison.OrdinalIgnoreCase) == 0)
      {
        return false;
      }
 
      if (!this.IsAlias(item.Template))
      {
        return false;
      }
 
      LinkField field = item.Fields["linked item"];
      Assert.IsNotNull(field, "field: linked item of " + item.Uri);
 
      if (string.IsNullOrWhiteSpace(field.Value))
      {
        return false;
      }
 
      if (field.TargetItem == null)
      {
        Log.Warn(this + " : TargetItem null", this);
        return false;
      }
 
      return field.TargetItem.ID.ToShortID().ToString();
    }
 
    private bool IsAlias(TemplateItem template)
    {
      if (template.ID == TemplateIDs.Alias)
      {
        return true;
      }
 
      return template.BaseTemplates.Any(
        baseTemplate => this.IsAlias(baseTemplate));
    }
  }
}

There is a bit of infrastructure in the provider itself to parse the options and pass them from the provider to the builder. Most of the actual work is in the LinkBuilder override, in which I included logic to handle aliases and slashes. The aliastarget field in the index will contain only the ID of the item the Linked Item field of items in a database other than core based on the alias template or a template that inherits from that template and have an item selected in that field. The AliasSearchResultItem internal class abstracts search hits on aliases for the provider. The TypeConverter attribute on the AliasTarget property converts the short ID string in the index back to a Sitecore item ID. To find the alias for an item as quickly as possible, this solution uses a custom index field in a search index. This should use resource at indexing rather than retrieval.

namespace SitecoreJohn.Links
{
  using System;
  using System.Collections.Specialized;
  using System.ComponentModel;
  using System.Linq;
 
  using Sitecore;
  using Sitecore.Configuration;
  using Sitecore.ContentSearch;
  using Sitecore.ContentSearch.Converters;
  using Sitecore.ContentSearch.SearchTypes;
  using Sitecore.Data.Items;
  using Sitecore.Diagnostics;
  using Sitecore.Links;
  using Sitecore.Sites;
  using Sitecore.Web;
 
  public class AliasAndSiteAwareLinkProvider : LinkProvider
  {
    public bool ApplyAliases { get; private set; }
 
    public bool AppendSlashes { get; private set; }
 
    // this shows how a LinkProvider can read custom config attributes
    // from web.config at startup.
    public override void Initialize(
      string name,
      NameValueCollection config)
    {
      // Go ahead and try not calling base.Initialize().
      // Go ahead. I dare you. I'll wait.
      base.Initialize(name, config);
      this.ApplyAliases = MainUtil.GetBool(
        config["applyAliases"],
        false);
      this.AppendSlashes = MainUtil.GetBool(
        config["appendSlashes"],
        false);
    }
 
    public override Sitecore.Links.UrlOptions
      GetDefaultUrlOptions()
    {
      Sitecore.Links.UrlOptions baseOptions =
        base.GetDefaultUrlOptions();
      SitecoreJohn.Links.UrlOptions customOptions =
        new SitecoreJohn.Links.UrlOptions(baseOptions);
      customOptions.ApplyAliases = this.ApplyAliases;
      customOptions.AppendSlashes = this.AppendSlashes;
      customOptions.SiteResolving = Settings.Rendering.SiteResolving;
      SiteContext site = Sitecore.Context.Site;
 
      if (site == null)
      {
        return customOptions;
      }
 
      customOptions.ApplyAliases = MainUtil.GetBool(
        site.Properties["applyAliases"],
        this.ApplyAliases);
      customOptions.AppendSlashes = MainUtil.GetBool(
        site.Properties["appendSlashes"],
        this.AppendSlashes);
      customOptions.SiteResolving = MainUtil.GetBool(
        site.Properties["siteResolving"],
        baseOptions.SiteResolving);
      customOptions.AddAspxExtension = MainUtil.GetBool(
        site.Properties["addAspxExtension"],
        baseOptions.AddAspxExtension);
      customOptions.AlwaysIncludeServerUrl = MainUtil.GetBool(
        site.Properties["alwaysIncludeServerUrl"],
        baseOptions.AddAspxExtension);
      customOptions.EncodeNames = MainUtil.GetBool(
        site.Properties["encodeNames"],
        baseOptions.EncodeNames);
      customOptions.LowercaseUrls = MainUtil.GetBool(
        site.Properties["lowercaseUrls"],
        baseOptions.LowercaseUrls);
      customOptions.UseDisplayName = MainUtil.GetBool(
        site.Properties["useDisplayName"],
        baseOptions.UseDisplayName);
      customOptions.ShortenUrls = MainUtil.GetBool(
        site.Properties["shortenUrls"],
        baseOptions.ShortenUrls);
      LanguageEmbedding languageEmbedding =
        baseOptions.LanguageEmbedding;
      Enum.TryParse(
        site.Properties["languageEmbedding"],
        true /*ignoreCase*/,
        out languageEmbedding);
      customOptions.LanguageEmbedding = languageEmbedding;
      LanguageLocation languageLocation =
        baseOptions.LanguageLocation;
      Enum.TryParse(
        site.Properties["languageLocation"],
        true /*ignoreCase*/,
        out languageLocation);
      customOptions.LanguageLocation = languageLocation;
      return customOptions;
    }
 
    protected override LinkProvider.LinkBuilder CreateLinkBuilder(
      Sitecore.Links.UrlOptions options)
    {
      return new
        SitecoreJohn.Links.AliasAndSiteAwareLinkProvider.LinkBuilder(
          options);
    }
 
    public new class LinkBuilder :
      Sitecore.Links.LinkProvider.LinkBuilder
    {
      // pass custom options from provider to builder
      public LinkBuilder(Sitecore.Links.UrlOptions options)
        : base(options)
      {
        SitecoreJohn.Links.UrlOptions customOptions =
          options as SitecoreJohn.Links.UrlOptions;
 
        if (customOptions == null)
        {
          Log.Warn(
            this + " : impossible condition : UrlOptions is not SitecoreJohn.Links.UrlOptions",
            this);
          return;
        }
 
        this.ApplyAliases = customOptions.ApplyAliases;
        this.AppendSlashes = customOptions.AppendSlashes;
      }
 
      private bool ApplyAliases { get; set; }
 
      private bool AppendSlashes { get; set; }
 
      protected override string BuildItemUrl(Item item)
      {
        Assert.ArgumentNotNull(item, "item");
 
        // ResolveTargetSite can set UrlOptions.Site,
        // so it and its location may be more important
        // than they at first appear.
        SiteInfo site = this.ResolveTargetSite(item);
 
        // the path part of the URL, whether to an item or an alias
        string path = null;
 
        // if configuration says provider should apply aliases...
        if (this.ApplyAliases)
        {
          using (var context = ContentSearchManager.GetIndex(
            new SitecoreIndexableItem(item)).CreateSearchContext())
          {
            // try to find an alias for the item.
            var result = context.GetQueryable<AliasSearchResultItem>().FirstOrDefault(
              entry => entry.AliasTarget == item.ID);
 
            if (result != null)
            {
              path = result.Path;
 
              // remove an unnecessary prefix.
              if (path.StartsWith(
                "/sitecore/system/aliases",
                StringComparison.InvariantCultureIgnoreCase))
              {
                path =
                  path.Substring("/sitecore/system/aliases".Length);
              }
            }
          }
        }
 
        // apparently there was no alias for the item.
        if (path == null)
        {
          path = this.GetItemPathElement(item, site);
        }
 
        if (path.Length == 0)
        {
          return string.Empty;
        }
 
        string serverUrlElement = this.GetServerUrlElement(site);
        string url;
 
        // if we need to prefix the path with a prototol and domain.
        if (site != null)
        {
          url = this.BuildItemUrl(
            serverUrlElement,
            path,
            site.VirtualFolder);
        }
        else
        {
          url = this.BuildItemUrl(serverUrlElement, path);
        }
 
        // append slash to path part of URL, if relevant.
        return this.HandleSlash(url);
      }
 
      private string HandleSlash(
        string url)
      {
        if (string.IsNullOrWhiteSpace(url) || !this.AppendSlashes)
        {
          return url;
        }
 
        // slash needs to appear before query string parameters
        int pos = url.IndexOf("?", StringComparison.Ordinal);
 
        if (pos < 0)
        {
          pos = url.Length;
        }
 
        string prefix = url.Substring(0, pos);
 
        if (!(prefix.EndsWith(".aspx")
          || prefix.EndsWith(".cshtml")
          || prefix.EndsWith("/")))
        {
          url = prefix + "/" + url.Substring(pos);
        }
 
        return url;
      }
    }
 
    private class AliasSearchResultItem : SearchResultItem
    {
      [TypeConverter(typeof(IndexFieldIDValueConverter))]
      public Sitecore.Data.ID AliasTarget { get; set; }
    }
  }
}

We need a Web.config include file to add the field to the index (and to configure the link provider). Note that the location has changed since I lased blogged about this topic, and that we should check out the ContentSearch.DefaultIndexConfigurationPath setting in the Web.config file.

<configuration xmlns:patch="https://www.sitecore.com/xmlconfig/">
  <sitecore>
    <contentSearch>
      <indexConfigurations>
        <defaultLuceneIndexConfiguration>
          <!-- setting name="ContentSearch.DefaultIndexConfigurationPath"
               value="contentSearch/indexConfigurations/defaultLuceneIndexConfiguration"
               patch:source="Sitecore.ContentSearch.config" -->
          <fields hint="raw:AddComputedIndexField">
            <field  fieldName="aliastarget"
                    storageType="no"
                    indexType="tokenized">SitecoreJohn.ContentSearch.ComputedFields.AliasTarget,SitecoreJohn</field>
          </fields>
        </defaultLuceneIndexConfiguration>
      </indexConfigurations>
    </contentSearch>
    <linkManager>
      <patch:attribute name="defaultProvider">custom</patch:attribute>
      <providers>
        <add name="sitecore">
          <patch:attribute
            name="name">custom</patch:attribute>
          <patch:attribute
            name="type">SitecoreJohn.Links.AliasAndSiteAwareLinkProvider,SitecoreJohn</patch:attribute>
        </add>
      </providers>
    </linkManager>
    <sites>
      <site name="website">
        <patch:attribute
          name="applyAliases">true</patch:attribute>
        <patch:attribute
          name="appendSlashes">true</patch:attribute>
        <patch:attribute
          name="alwaysIncludeServerUrl">true</patch:attribute>
        <patch:attribute
          name="languageEmbedding">always</patch:attribute>
        <patch:attribute
          name="languageLocation">queryString</patch:attribute>
      </site>
    </sites>
  </sitecore>
</configuration>

Warning

Other than confirming that it generated URLs that appeared correct in a couple out of thousands of possible configurations, I did not test this provider at all. It could have negative impacts on other areas of the system, such as resolving the context item in the httpRequestBegin pipeline.

Resources

  • This can demonstrate a problem for anything that creates UrlOptions rather than using GetDefaultUrlOptions() from the provider, which I would classify as a defect (maybe the constructor for UrlOptions should be restricted?).  I found this due to an exception raised by Sitecore.ContentSearch.ComputedFields.UrlLink. To hack around it, I changed this class:        public LinkBuilder(Sitecore.Links.UrlOptions options)         : base(options)       {         SitecoreJohn.Links.UrlOptions customOptions =           options as SitecoreJohn.Links.UrlOptions;          if (customOptions != null)         {           this.ApplyAliases = customOptions.ApplyAliases;           this.AppendSlashes = customOptions.AppendSlashes;         }         else         { //          Log.Warn( //            this + " : UrlOptions is not SitecoreJohn.Links.UrlOptions" + Environment.NewLine + Environment.StackTrace, //            this); //          this.ApplyAliases = //TODO: //          this.AppendSlashes = //TODO: //          return;         }