Routing two Episerver Commerce catalog nodes to different URLs

I was recently confronted with a routing issue whereby two nodes in an Episerver Commerce catalog represented significantly different types of products, as such it was a requirement that they were routed to significantly different URLs within the website.

For me to better illustrate this let’s take as an example the Episerver Quicksilver reference site. The default URLs for the Mens and Womens ‘Fashion Nodes’ would be:

  • /en/fashion/mens/
  • /en/fashion/womens/

Here 'fashion' represents the catalog with 'mens' and 'womens' representing the individual nodes, let’s say, for the sake of this example, that we want to route them to:

  • /en/products/secondary/
  • /en/womens/

It’s a triviality to remove the 'fashion' catalog name from the route (in fact Quan Mai has a solution to this in his book, see problem 1.5 in the sample). However, what we’re trying to do is something a little more tricky — we’re trying to route the different nodes to completely different URLs within the site.

With that verbose introduction laid down, let’s discuss a solution!

Attempt 1: Registering two HierarchicalCatalog­PartialRouters

The first solution may appear to be as simple as to forgo using the CatalogRouteHelper MapDefaultHierarchialRouter method and registering two HierarchicalCatalogPartialRouters.

As a rough example in code I’d expect this to look something like:

var referenceConverter = context.Locate.Advanced.GetInstance<ReferenceConverter>();
var contentLoader = context.Locate.Advanced.GetInstance<IContentLoader>();

var mensCatalogContent = contentLoader.Get<CatalogContentBase>(referenceConverter.GetContentLink("mens"));
var womensCatalogContent = contentLoader.Get<CatalogContentBase>(referenceConverter.GetContentLink("womens"));

// The content references should represent the content we want our catalog content
// to be routed under.
RouteTable.Routes.RegisterPartialRouter(new HierarchicalCatalogPartialRouter(() => new ContentReference(1), mensCatalogContent, false));
RouteTable.Routes.RegisterPartialRouter(new HierarchicalCatalogPartialRouter(() => new ContentReference(2), womensCatalogContent, false));

This looks like a pretty attractive solution on paper (or on screen). However, Episerver doesn’t support multiple HierarchicalCatalogPartialRouters (see here) and what you’ll find is that only the first one of these you register will work (in this instance — the 'mens' node).

Ok, so having explored this route and hit a dead-end, let’s see if we can’t back-up and try something else, namely:

Attempt 2: A custom HierarchicalCatalog­PartialRouter

The second option is to register a custom HierarchicalCatalogPartialRouter which routes the two different nodes to two different URLs. While doing this we want to maintain the hierarchical structure and make as few changes as possible to the code.

The first step is pretty straightforward, we need to override the TryGetVirtualPath method (which "Tries to retrieve the virtual path for the content."). If the content we're getting the path for is one of our chosen nodes then we can just set the virtualPath string to the URL and return true, otherwise we use the base TryGetVirtualPath implementation:

protected override bool TryGetVirtualPath(HttpContextBase context, CatalogContentBase content, string language,
    out string virtualPath)
{
    // If we hit the mens node, we don't build the rest of the hierarchical
    // URL, instead we can just return the relative URL.
    if (content.ContentLink.CompareToIgnoreWorkID(_referenceConverter.GetContentLink("mens")))
    {
        virtualPath = "products/secondary/";
        return true;
    }

    // Same for the womens node.
    if (content.ContentLink.CompareToIgnoreWorkID(_referenceConverter.GetContentLink("womens")))
    {
        virtualPath = "womens/";
        return true;
    }

    // Otherwise, build the correct hierarchical URL.
    return base.TryGetVirtualPath(context, content, language, out virtualPath);
}

With this change we'll see the URLs we want on the site but clicking them will give a 404. To ensure we return the right content the easiest approach is overriding the GetCatalogContentRecursive method (this gets the content based on the segment context by recursing all segments).

What we need to do here is ensure that when we hit either of our custom node URLs is resolve the correct content and set the next segment value to be correct — allowing the default HierarchicalCatalogPartialRouter to do the rest:

protected override CatalogContentBase GetCatalogContentRecursive(CatalogContentBase catalogContent,
    SegmentPair segmentPair, SegmentContext segmentContext, CultureInfo cultureInfo)
{
    const string mensUrl = "products/secondary/";
    const string womensUrl = "womens/";

    var mensNodeLink = _referenceConverter.GetContentLink("mens");
    var womensNodeLink = _referenceConverter.GetContentLink("womens");

    // Get all the unhandled segments
    var segments = $"{segmentPair.Next}/{segmentPair.Remaining}";

    // If our unhandled segments match our mens URL, then we resolve the correct next unhandled segment
    // and pass this as well as the mens node content to the default method.
    if (segments.StartsWith(mensUrl, StringComparison.OrdinalIgnoreCase))
    {
        var remainingSegments = segments.Substring(mensUrl.Length);
        var nextValue = segmentContext.GetNextValue(remainingSegments);

        var mensNode = _contentLoader.Get<CatalogContentBase>(mensNodeLink);

        return base.GetCatalogContentRecursive(mensNode, nextValue, segmentContext, cultureInfo);
    }

    // ...and the same for the womens URL.
    if (segments.StartsWith(womensUrl, StringComparison.OrdinalIgnoreCase))
    {
        var remainingSegments = segments.Substring(womensUrl.Length);
        var nextValue = segmentContext.GetNextValue(remainingSegments);

        var womensNode = _contentLoader.Get<CatalogContentBase>(womensNodeLink);

        return base.GetCatalogContentRecursive(womensNode, nextValue, segmentContext, cultureInfo);
    }

    // If our content is either the mens or womens node and the URL didn't match above
    // then we have hit the standard hierarchical route and should return null to avoid
    // duplicate content.
    if (catalogContent.ContentLink.CompareToIgnoreWorkID(mensNodeLink) ||
        catalogContent.ContentLink.CompareToIgnoreWorkID(womensNodeLink))
    {
        return null;
    }

    return base.GetCatalogContentRecursive(catalogContent, segmentPair, segmentContext, cultureInfo);
}

Putting this all together we end up with something that looks very much like:

internal class CustomPartialRouter : HierarchicalCatalogPartialRouter
{
    private readonly ContentReference _mensNodeLink;
    private readonly ContentReference _womensNodeLink;

    private readonly IContentLoader _contentLoader;

    // I doubt you want to hard-code these!
    private const string WomensUrl = "womens/";
    private const string MensUrl = "products/secondary/";

    public CustomPartialRouter(Func<ContentReference> routeStartingPoint, CatalogContentBase commerceRoot,
        bool enableOutgoingSeoUri) : base(routeStartingPoint, commerceRoot, enableOutgoingSeoUri)
    {
        var referenceConverter = ServiceLocator.Current.GetInstance<ReferenceConverter>();
        _mensNodeLink = referenceConverter.GetContentLink("mens");
        _womensNodeLink = referenceConverter.GetContentLink("womens");

        _contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
    }

    protected override CatalogContentBase GetCatalogContentRecursive(CatalogContentBase catalogContent,
        SegmentPair segmentPair, SegmentContext segmentContext, CultureInfo cultureInfo)
    {
        // Get all the unhandled segments
        var segments = $"{segmentPair.Next}/{segmentPair.Remaining}";

        // If our unhandled segments match our mens URL, then we resolve the correct next unhandled segment
        // and pass this as well as the mens node content to the default method.
        if (segments.StartsWith(MensUrl, StringComparison.OrdinalIgnoreCase))
        {
            var remainingSegments = segments.Substring(MensUrl.Length);
            var nextValue = segmentContext.GetNextValue(remainingSegments);

            return base.GetCatalogContentRecursive(_contentLoader.Get<CatalogContentBase>(_mensNodeLink), nextValue, segmentContext, cultureInfo);
        }

        // ...and the same for the womens URL.
        if (segments.StartsWith(WomensUrl, StringComparison.OrdinalIgnoreCase))
        {
            var remainingSegments = segments.Substring(WomensUrl.Length);
            var nextValue = segmentContext.GetNextValue(remainingSegments);

            return base.GetCatalogContentRecursive(_contentLoader.Get<CatalogContentBase>(_womensNodeLink), nextValue, segmentContext, cultureInfo);
        }

        // If our content is either the mens or womens node and the URL didn't match above
        // then we have hit the standard hierarchical route and should return null to avoid
        // duplicate content.
        if (catalogContent.ContentLink.CompareToIgnoreWorkID(_mensNodeLink) ||
            catalogContent.ContentLink.CompareToIgnoreWorkID(_womensNodeLink))
        {
            return null;
        }

        return base.GetCatalogContentRecursive(catalogContent, segmentPair, segmentContext, cultureInfo);
    }

    protected override bool TryGetVirtualPath(HttpContextBase context, CatalogContentBase content, string language,
        out string virtualPath)
    {
        // If we hit the mens node, we don't build the rest of the hierarchical
        // URL, instead we can just return the relative URL.
        if (content.ContentLink.CompareToIgnoreWorkID(_mensNodeLink))
        {
            virtualPath = MensUrl;
            return true;
        }

        // Same for the womens node.
        if (content.ContentLink.CompareToIgnoreWorkID(_womensNodeLink))
        {
            virtualPath = WomensUrl;
            return true;
        }

        // Otherwise, build the correct hierarchical URL.
        return base.TryGetVirtualPath(context, content, language, out virtualPath);
    }
}

Finally, registering this is just as simple as registering the standard HierarchicalCatalogPartialRouter:

var referenceConverter = context.Locate.Advanced.GetInstance<ReferenceConverter>();
var contentLoader = context.Locate.Advanced.GetInstance<IContentLoader>();

var commerceRootContent = contentLoader.Get<CatalogContentBase>(referenceConverter.GetRootLink());
RouteTable.Routes.RegisterPartialRouter(new CustomPartialRouter(() => SiteDefinition.Current.RootPage,
    commerceRootContent, false));

If you've followed the above (and are using the Quicksilver solution) you should now have different (and custom) URLs for the womens and mens catalog nodes, similarly, because we built our custom router on the HierarchicalCatalogPartialRouter we still get the underlying hierarchical functionality.

In the interests of keeping the code above clear and straightforward I took the liberty of hard-coding some URLs and content references (specific to the Quicksilver solution), I suspect it's obvious that this probably isn't something you want to do but I thought I'd take a moment to point that out. Nevertheless, hopefully this blog post provides you with some inspiration when approaching something similar.

Comments

There are zero comments 😢