Modify Umbraco URLs with the UrlProvider and ContentFinder

I'm Jeroen Breuer and I work as a developer at Have A Nice Day Online. These days Umbraco has a lot of public APIs which make it possible to modify URLs. This is done with the help of an UrlProvider and ContentFinder.

Change the home URL

For years I've been using the "ultimate" site structure setup. Sebastiaan's post might be old, but it's still pretty up to date. The only part which can be improved these days is that after you've set the internal redirect the homepage URL is still /home. You can change this with the following UrlProvider:

public class HomeUrlProvider : IUrlProvider
{
    public string GetUrl(UmbracoContext umbracoContext, int id, Uri current, UrlProviderMode mode)
    {
        var content = umbracoContext.ContentCache.GetById(id);
        if (content != null && content.DocumentTypeAlias == "Home" && content.Parent != null)
        {
            //The home node will have / instead of /home/.
            return content.Parent.Url;
        }

        return null;
    }

    public IEnumerable<string> GetOtherUrls(UmbracoContext umbracoContext, int id, Uri current)
    {
        return Enumerable.Empty<string>();
    }
}

After this the homepage URL will become the URL of its parent which is the site node. Even in the backoffice. The internal redirect is still active so visiting the parent URL will show the homepage. You don't need the urlRewrite anymore. The HomeUrlProvider needs to be registered, but that code will be shown below. This is also part of the Hybrid Framework.

Add an extra segment in the URL

Part 1: The UrlProvider

In Umbraco the URL structure is pretty straightforward. The URL is the same as the tree structure. All the node's parents are segments in the URL, but what if you want to add a segment that's not a parent node? For example a date in a news page. The following UrlProvider will add the date as a segment to the URL.

public class NewsUrlProvider : IUrlProvider
{
    public string GetUrl(UmbracoContext umbracoContext, int id, Uri current, UrlProviderMode mode)
    {
        var content = umbracoContext.ContentCache.GetById(id);
        if (content != null && content.DocumentTypeAlias == "Newsitem" && content.Parent != null)
        {
            var date = content.GetPropertyValue<DateTime>("date");
            if(date != null)
            {
                //This will add the selected date before the node name.
                //For example /news/item1/ becomes /news/28-07-2014/item1/.
                var url = content.Parent.Url;
                if (!(url.EndsWith("/")))
                {
                    url += "/";
                }
                return url + date.ToString("dd-MM-yyyy") + "/" + content.UrlName + "/";
            }
        }

        return null;
    }

    public IEnumerable<string> GetOtherUrls(UmbracoContext umbracoContext, int id, Uri current)
    {
        return Enumerable.Empty<string>();
    }
}

Part 2: The ContentFinder

Now that the URL has an extra segment you'll get a 404 when trying to visit the page. That's because the URL segments don't match the tree structure anymore. We need to create a ContentFinder that returns the correct node based on the URL. The following ContentFinder does this for the news.

public class NewsContentFinder : IContentFinder
{
    public bool TryFindContent(PublishedContentRequest contentRequest)
    {
        try
        {
            if (contentRequest != null)
            {
                //Get the current url.
                var url = contentRequest.Uri.AbsoluteUri;

                //Get the news nodes that are already cached.
                var cachedNewsNodes = (Dictionary<string, ContentFinderItem>)HttpContext.Current.Cache["CachedNewsNodes"];
                if (cachedNewsNodes != null)
                {
                    //Check if the current url already has a news item.
                    if (cachedNewsNodes.ContainsKey(url))
                    {
                        //If the current url already has a node use that so the rest of the code doesn't need to run again.
                        var contentFinderItem = cachedNewsNodes[url];
                        contentRequest.PublishedContent = contentFinderItem.Content;
                        contentRequest.TrySetTemplate(contentFinderItem.Template);
                        return true;
                    }
                }

                //Split the url segments.
                var path = contentRequest.Uri.GetAbsolutePathDecoded();
                var parts = path.Split(new[] { '/' }, System.StringSplitOptions.RemoveEmptyEntries);

                //The news items should contain 3 segments.
                if (parts.Length == 3)
                {
                    //Get all the root nodes.
                    var rootNodes = contentRequest.RoutingContext.UmbracoContext.ContentCache.GetAtRoot();

                    //Find the news item that matches the last segment in the url.
                    var newsItem = rootNodes.DescendantsOrSelf("Newsitem").Where(x => x.UrlName == parts.Last()).FirstOrDefault();
                    if(newsItem != null)
                    {
                        //Get the news item template.
                        var template = Services.FileService.GetTemplate(newsItem.TemplateId);

                        if (template != null)
                        {
                            //Store the fields in the ContentFinderItem-object.
                            var contentFinderItem = new ContentFinderItem()
                            {
                                Template = template.Alias,
                                Content = newsItem
                            };

                            //If the correct node is found display that node.
                            contentRequest.PublishedContent = contentFinderItem.Content;
                            contentRequest.TrySetTemplate(contentFinderItem.Template);

                            if (cachedNewsNodes != null)
                            {
                                //Add the new ContentFinderItem-object to the cache.
                                cachedNewsNodes.Add(url, contentFinderItem);
                            }
                            else
                            {
                                //Create a new dictionary and store it in the cache.
                                cachedNewsNodes = new Dictionary<string, ContentFinderItem>()
                                {
                                    {
                                        url, contentFinderItem
                                    }
                                };
                                HttpContext.Current.Cache.Add("CachedNewsNodes",
                                        cachedNewsNodes,
                                        null,
                                        DateTime.Now.AddDays(1),
                                        System.Web.Caching.Cache.NoSlidingExpiration,
                                        System.Web.Caching.CacheItemPriority.High,
                                        null);
                            }
                        }
                    }
                }
            }
        }
        catch (Exception ex)
        {
            Umbraco.LogException<NewsContentFinder>(ex);
        }

        return contentRequest.PublishedContent != null;
    }
}

Currently the code just looks for a news node which matches the name of the last part of the URL. That could probably be improved, but for this example it's fine.

Part 3: Clear the cache

After the node is found it's also added in a custom cache layer. Because of that the ContentFinder doesn't need to look for the node each time the page is requested. Make sure to clear the cache after save and publish because otherwise you'll get a cached node which is not up to date:

ContentService.Published += Content_Published;
private void Content_Published(IPublishingStrategy sender, PublishEventArgs<IContent> e)
{
    //Clear the content finder cache.
    HttpContext.Current.Cache.Remove("CachedNewsNodes");
}

Part 4: Register the UrlProvider and ContentFinder

The above code won't work just yet. It needs to be registered in the ApplicationStarting event.

protected override void ApplicationStarting(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
{
    //With the url providers we can change node urls.
    UrlProviderResolver.Current.InsertTypeBefore<DefaultUrlProvider, HomeUrlProvider>();
    UrlProviderResolver.Current.InsertTypeBefore<DefaultUrlProvider, NewsUrlProvider>();

    //With the content finder we can match nodes to urls.
    ContentFinderResolver.Current.InsertTypeBefore<ContentFinderByNotFoundHandlers, NewsContentFinder>();
}

After this your news items will have the date in the URL and visiting the modified URL will still return the correct news item.

The NewsUrlProvider and NewsContentFinder are also part of the Hybrid Framework best practises so you can find a working example there.

The possibilities

With the UrlProvider and ContentFinder nothing is impossible with URLs in Umbraco. On a big project at work we even completly replaced the default UrlProvider and ContentFinder that Umbraco uses. By default Umbraco only supports setting a domain at a single level. If you have domains at multiple levels only the URLs for the deepest domain in the tree are generated. For this website we needed all URLs for all the domains at multiple levels. For example a page needed to be accessible at www.website.com/level1/level2/level3/, but also at www.level2-website.com/level3/. So the website node has a domain and also the level2 node. Without changing the source code we were able to support this. The following code switched the default UrlProvider and ContentFinder with our own:

protected override void ApplicationStarting(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
{
    //With the url providers we can change node urls.
    UrlProviderResolver.Current.InsertTypeBefore<DefaultUrlProvider, DomainUrlProvider>();

    //Remove the DefaultUrlProvider because our DomainUrlProvider should take care of all the urls.
    UrlProviderResolver.Current.RemoveType<DefaultUrlProvider>();
            
    //With the content finder we can match nodes to urls.
    ContentFinderResolver.Current.InsertTypeBefore<ContentFinderByNiceUrl, DomainContentFinder>();

    //Remove the ContentFinderByNiceUrl because our DomainContentFinder should find all the content.
    ContentFinderResolver.Current.RemoveType<ContentFinderByNiceUrl>();
}

The downside

Modifying URLs is easy, but currently there is a downside. Packages like the 301 URL Tracker and SEO Checker don't do a 301 redirect from the old URL to the new URL. So if you change the node name of a news item from the above example you need to add a 301 manually from the old URL. 

It might be possible to automate this with some events, but I haven't tried that yet. 

Thanks

I would like thank Stephan Gay for making all of this possible in Umbraco. In the last couple of months he's been a great help and I couldn't have done this without him. #h5yr

Jeroen Breuer

Jeroen is on Twitter as