Jonas Bergqvist
Jan 7, 2016
  7278
(3 votes)

Automatic landing page

An “Automatic landing page” is content (for example an page type instance) which renders differently depending on user input. A form of personalized content-type. A google hit can, for example, point to an automatic landing page, which shows content related to the google search.

This is the first of a blog series about automatic landing pages. This first blog post is an overview over what automatic landing pages are about. The next blog will show how you can implement automatic landing pages in Quicksilver (Commerce demo site) with the search provider system. Another one will show how to use native Find in Quicksilver to accomplish the same thing.

Partial route

The challenge in building an automatic landing page is to make a search engine accept dynamic rendered content as different search hits. That is easiest accomplished by creating a partial route.

One url will render one unique “page” for one content instance. Different url:s are not supposed to render the same content. A partial route makes it possible to use a nice url instead of query parameters to specify what content that should be rendered on a page. Look at the following example, taken from the Quicksilver implementation, which goes to the same content instance:

  • http://quicksilver/en/fashion/mens/Category/jackets/Color/blue: Blue jackets for men.
  • http://quicksilver/en/fashion/womens/Category/dresses/Color/black/Size/m: Black dresses in size medium for women.

A partial route can be used to both create url:s and read incoming requests. I will only use the partial route for reading incoming requests. The rendering of url:s will be done in a separate service. The examples use Episerver Commerce, but it’s possible to make the same functionality for CMS sites as well.

I will create a class that inherits from "HierarchicalCatalogPartialRouter", and override "RoutePartial". This method should first get the catalog content by calling the base method. Then it’s time to figure out when the rest of the url means.

public class FacetPartialRoute : HierarchicalCatalogPartialRouter
    {
        private readonly FacetUrlService _facetUrlCreator;

        public FacetPartialRoute(Func<ContentReference> routeStartingPoint, CatalogContentBase commerceRoot,
            bool enableOutgoingSeoUri)
            : this(
            routeStartingPoint, 
            commerceRoot, 
            enableOutgoingSeoUri,
            ServiceLocator.Current.GetInstance<IContentLoader>(),
            ServiceLocator.Current.GetInstance<IRoutingSegmentLoader>(),
            ServiceLocator.Current.GetInstance<IContentVersionRepository>(), 
            ServiceLocator.Current.GetInstance<IUrlSegmentRouter>(),
            ServiceLocator.Current.GetInstance<IContentLanguageSettingsHandler>(),
            ServiceLocator.Current.GetInstance<FacetUrlService>())
        {
        }

        [DefaultConstructor]
        public FacetPartialRoute(Func<ContentReference> routeStartingPoint, CatalogContentBase commerceRoot,
            bool supportSeoUri, IContentLoader contentLoader, IRoutingSegmentLoader routingSegmentLoader,
            IContentVersionRepository contentVersionRepository, IUrlSegmentRouter urlSegmentRouter,
            IContentLanguageSettingsHandler contentLanguageSettingsHandler,
            FacetUrlService facetUrlCreator)
            : base(
                routeStartingPoint, commerceRoot, supportSeoUri, contentLoader, routingSegmentLoader,
                contentVersionRepository, urlSegmentRouter, contentLanguageSettingsHandler)
        {
            _facetUrlCreator = facetUrlCreator;
        }

        public override object RoutePartial(PageData content, SegmentContext segmentContext)
        {
            var routedContet = base.RoutePartial(content, segmentContext);

            var segmentPair = segmentContext.GetNextValue(segmentContext.RemainingPath);
            if (String.IsNullOrEmpty(segmentPair.Next))
            {
                return routedContet;
            }

            var facetNames = _facetUrlCreator.GetFacetModels().ToArray();

            var nextSegment = _facetUrlCreator.GetFacetValue(facetNames, segmentPair.Next);
            if (String.IsNullOrEmpty(nextSegment))
            {
                return routedContet;
            }

            var routeFacets = segmentContext.RouteData.Values[FacetUrlService.RouteFacets] as ConcurrentDictionary<RouteFacetModel, HashSet<object>>;
            if (routeFacets == null)
            {
                segmentContext.RouteData.Values[FacetUrlService.RouteFacets] = new ConcurrentDictionary<RouteFacetModel, HashSet<object>>();
                routeFacets = (ConcurrentDictionary<RouteFacetModel, HashSet<object>>)segmentContext.RouteData.Values[FacetUrlService.RouteFacets];
            }

            AddFacetsToSegmentContext(routeFacets, segmentContext, facetNames, nextSegment, segmentPair.Remaining, null);
            return routedContet;
        }

        private void AddFacetsToSegmentContext(ConcurrentDictionary<RouteFacetModel, HashSet<object>> routeFacets, SegmentContext segmentContext, RouteFacetModel[] facetNames, string nextSegment, string remaining, RouteFacetModel currentFacet)
        {
            if (String.IsNullOrEmpty(nextSegment))
            {
                return;
            }

            var value = facetNames.FirstOrDefault(x => x.FacetName == nextSegment);
            if (value != null)
            {
                currentFacet = value;
                
            }
            else if (currentFacet != null)
            {
                var facetValue = _facetUrlCreator.GetFacetValue(facetNames, nextSegment);

                routeFacets.AddOrUpdate(currentFacet,
                   (key) => new HashSet<object> { facetValue },
                   (key, list) =>
                   {
                       list.Add(facetValue);
                       return list;
                   });
            }

            segmentContext.RemainingPath = remaining;

            var segmentPair = segmentContext.GetNextValue(segmentContext.RemainingPath);
            nextSegment = _facetUrlCreator.GetFacetValue(facetNames, segmentPair.Next);

            AddFacetsToSegmentContext(routeFacets, segmentContext, facetNames, nextSegment, segmentPair.Remaining, currentFacet);
        }
    }

Route registration

We need a way of registering our route(s). We will create a class for the registrations called CatalogContentRouteRegistration:

[ServiceConfiguration(Lifecycle = ServiceInstanceScope.Singleton)]
    public class CatalogContentRouteRegistration
    {
        private readonly IContentLoader _contentLoader;
        private readonly ReferenceConverter _referenceConverter;

        public CatalogContentRouteRegistration(IContentLoader contentLoader, ReferenceConverter referenceConverter)
        {
            _contentLoader = contentLoader;
            _referenceConverter = referenceConverter;
        }

        public void RegisterDefaultRoute()
        {
            RegisterDefaultRoute(false);
        }

        public void RegisterDefaultRoute(bool enableOutgoingSeoUri)
        {
            var commerceRootContent = _contentLoader.Get<CatalogContentBase>(_referenceConverter.GetRootLink());

            var pageLink = ContentReference.IsNullOrEmpty(SiteDefinition.Current.StartPage)
                ? SiteDefinition.Current.RootPage
                : SiteDefinition.Current.StartPage;

            RegisterRoute(pageLink, commerceRootContent, enableOutgoingSeoUri);
        }

        public void RegisterRoute(ContentReference pageLink, ContentReference catalogLink, bool enableOutgoingSeoUri)
        {
            var commerceRootContent = _contentLoader.Get<CatalogContentBase>(catalogLink);
            RegisterRoute(pageLink, commerceRootContent, enableOutgoingSeoUri);
        }

        public void RegisterRoute(ContentReference pageLink, CatalogContentBase catalogContentBase, bool enableOutgoingSeoUri)
        {
            RouteTable.Routes.RegisterPartialRouter(new FacetPartialRoute(() => pageLink, catalogContentBase, enableOutgoingSeoUri));
        }
    }

FacetUrlService

I will create a new service named “FacetUrlService”. The main responsibility for this service is to create a virtual path for selected facets on a site. A method called “GetFilterPath” will take the standard virtual path as one parameter, and a dictionary containing selected facets as the other parameter. The return value will be a string containing the virtual path including facets from the parameter.

[ServiceConfiguration(Lifecycle = ServiceInstanceScope.Singleton)]
    public class FacetUrlService
    {
        public const string RouteFacets = "routeFacets";
        private readonly DynamicDataStoreFactory _dynamicDataStoreFactory;
        private readonly ISynchronizedObjectInstanceCache _objectInstanceCache;
        private readonly UrlResolver _urlResolver;

        public FacetUrlService(DynamicDataStoreFactory dynamicDataStoreFactory, ISynchronizedObjectInstanceCache objectInstanceCache, UrlResolver urlResolver)
        {
            _dynamicDataStoreFactory = dynamicDataStoreFactory;
            _objectInstanceCache = objectInstanceCache;
            _urlResolver = urlResolver;
        }

        public IEnumerable<RouteFacetModel> GetFacetModels()
        {
            var facetNames = GetCachedFacetNames();
            if (facetNames != null)
            {
                return facetNames;
            }

            var routingFacetNameStore = GetRoutingFacetNameStore();
            var allRouteFacetModels = routingFacetNameStore.LoadAll<RouteFacetModel>();

            var cacheKey = GetCacheName();
            _objectInstanceCache.Insert(cacheKey, allRouteFacetModels, new CacheEvictionPolicy(new string[0]));

            return allRouteFacetModels;
        }

        internal string GetUrl(IContent currentContent, RouteValueDictionary routeValues, string facetType, string facetKeyPath, string facetKey, object facetValue)
        {
            var originalRouteFacets = routeValues[RouteFacets] as ConcurrentDictionary<RouteFacetModel, HashSet<object>>;

            var routeFacets = new Dictionary<RouteFacetModel, HashSet<object>>();
            if (originalRouteFacets != null)
            {
                foreach (var routeFacetModel in originalRouteFacets.Keys)
                {
                    routeFacets.Add(routeFacetModel, new HashSet<object>());
                    foreach (var value in originalRouteFacets[routeFacetModel])
                    {
                        routeFacets[routeFacetModel].Add(value);
                    }
                }
            }

            var model = routeFacets.Select(x => x.Key).SingleOrDefault(x => x.FacetName == facetKey);
            if (model != null)
            {
                routeFacets[model].Add(facetValue);
            }
            else
            {
                model = new RouteFacetModel
                {
                    FacetName = facetKey,
                    FacetPath = facetKeyPath,
                    FacetType = facetType
                };
                routeFacets.Add(model, new HashSet<object> { facetValue });
            }

            string language = null;
            var languageContent = currentContent as ILocalizable;
            if (languageContent != null)
            {
                language = languageContent.Language.Name;
            }

            var url = _urlResolver.GetUrl(currentContent.ContentLink, language);
            return url.Length > 1 ? GetUrl(url.Substring(0, url.Length - 1), routeFacets) : url;
        }

        internal string GetUrl(string partialVirtualPath, IDictionary<RouteFacetModel, HashSet<object>> routeFacets)
        {
            var path = new StringBuilder(partialVirtualPath);

            var routeFacetKeys = routeFacets.Keys.OrderBy(x => x.FacetName);
            foreach (var routeFacetKey in routeFacetKeys)
            {
                HashSet<object> keyValues;
                if (routeFacets.TryGetValue(routeFacetKey, out keyValues))
                {
                    SaveIfNotExist(routeFacetKey);
                    path.Append(String.Concat("/", routeFacetKey.FacetName));

                    var keyValueStrings = keyValues.Select(x => x.ToString()).OrderBy(x => x);
                    foreach (var keyValueString in keyValueStrings)
                    {
                        var facetValue = GetFacetValueWhenCreatingUrl(keyValueString);
                        path.Append(String.Concat("/", facetValue));
                    }
                }
            }

            return path.ToString();
        }

        internal string GetFacetValue(IEnumerable<RouteFacetModel> facetNames, string originalName)
        {
            var possibleProblems = facetNames.Where(x => x.FacetName.EndsWith(originalName));
            if (!possibleProblems.Any())
            {
                return originalName;
            }

            var modifiedName = originalName;
            while (modifiedName.Length > 0)
            {
                modifiedName = modifiedName.Substring(1);
                if (!facetNames.Any(x => x.FacetName.EndsWith(originalName)))
                {
                    return modifiedName;
                }
            }

            return originalName;
        }

        private string GetFacetValueWhenCreatingUrl(string originalName)
        {
            var facetNames = GetFacetModels();
            return GetFacetValueWhenCreatingUrl(facetNames, originalName);
        }

        private static string GetFacetValueWhenCreatingUrl(IEnumerable<RouteFacetModel> facetNames, string originalName)
        {
            if (facetNames == null || !facetNames.Any(x => x.FacetName == originalName))
            {
                return originalName;
            }

            return GetFacetValueWhenCreatingUrl(facetNames, String.Concat("f", originalName));
        }

        private void SaveIfNotExist(RouteFacetModel facetName)
        {
            var facetNames = GetFacetModels();
            if (facetNames != null && facetNames.Any(x => x.FacetName == facetName.FacetName))
            {
                return;
            }

            var routingFacetNameStore = GetRoutingFacetNameStore();
            routingFacetNameStore.Save(facetName);
            ClearFacetNamesCache();
        }

        private IEnumerable<RouteFacetModel> GetCachedFacetNames()
        {
            return _objectInstanceCache.Get(GetCacheName()) as IEnumerable<RouteFacetModel>;
        }

        private void ClearFacetNamesCache()
        {
            _objectInstanceCache.Remove(GetCacheName());
        }

        private static string GetCacheName()
        {
            return "bc:routingfacetnames";
        }

        private DynamicDataStore GetRoutingFacetNameStore()
        {
            const string routingFacetNames = "RoutingFacetNames";
            return _dynamicDataStoreFactory.GetStore(routingFacetNames) ??
                _dynamicDataStoreFactory.CreateStore(routingFacetNames, typeof(RouteFacetModel));
        }
    }

HtmlHelper

The last thing I need is a HtmlHelper extension method, which will take the current content, the facet name, and facet value as parameters. We will then call FacetUrlService with the current url and facets to get a new url for a specific facet value.

We can now call this helper method for every facet value in a facet navigation. The url:s will be unique for every possible combination if we make sure to sort the facets and facet values in our service. The url:s will also be normal url:s that a crawler easily can index. This can make a crawler index every possible facet combination on your facet navigated page as individual pages.

        public static MvcHtmlString FacetContentUrl(this HtmlHelper htmlHelper, IContent currentContent, string facetType, string facetKeyPath, string facetKey, object facetValue)
        {
            var url = ServiceLocator.Current.GetInstance<FacetUrlService>().GetUrl(currentContent, htmlHelper.ViewContext.RouteData.Values, facetType, facetKeyPath, facetKey, facetValue);

            return new MvcHtmlString(url);
        }

Implementation on Quicksilver

I will make a new blog post soon where I use the nuget package blow to implement this on Quicksilver.

Source code

https://github.com/jonasbergqvist/BrilliantCut.AutomaticLandingPage

Nuget package

The easiest way to install the packages are to create a new package source in visual studio that points to "http://nuget.jobe.employee.episerver.com/" (https://docs.nuget.org/consume/Package-Manager-Dialog). Now it's possible to install the packages by writing "install-package brilliantcut.automaticlandingpage" in the "Package manager console".

Jan 07, 2016

Comments

Please login to comment.
Latest blogs
Increase timeout for long running SQL queries using SQL addon

Learn how to increase the timeout for long running SQL queries using the SQL addon.

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Overriding the help text for the Name property in Optimizely CMS

I recently received a question about how to override the Help text for the built-in Name property in Optimizely CMS, so I decided to document my...

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Resize Images on the Fly with Optimizely DXP's New CDN Feature

With the latest release, you can now resize images on demand using the Content Delivery Network (CDN). This means no more storing multiple versions...

Satata Satez | Dec 19, 2024

Simplify Optimizely CMS Configuration with JSON Schema

Optimizely CMS is a powerful and versatile platform for content management, offering extensive configuration options that allow developers to...

Hieu Nguyen | Dec 19, 2024

Useful Optimizely CMS Web Components

A list of useful Optimizely CMS components that can be used in add-ons.

Bartosz Sekula | Dec 18, 2024 | Syndicated blog

SaaS CMS - Pages and Blocks get the Visual Builder Treatment

I’m thrilled to see that Optimizely has now enabled Visual Builder for OG Pages and Blocks within SaaS CMS, and I’m guessing this will become...

Minesh Shah (Netcel) | Dec 17, 2024