November Happy Hour will be moved to Thursday December 5th.

Jonas Bergqvist
Jan 7, 2016
  7238
(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
Optimizely SaaS CMS + Coveo Search Page

Short on time but need a listing feature with filters, pagination, and sorting? Create a fully functional Coveo-powered search page driven by data...

Damian Smutek | Nov 21, 2024 | Syndicated blog

Optimizely SaaS CMS DAM Picker (Interim)

Simplify your Optimizely SaaS CMS workflow with the Interim DAM Picker Chrome extension. Seamlessly integrate your DAM system, streamlining asset...

Andy Blyth | Nov 21, 2024 | Syndicated blog

Optimizely CMS Roadmap

Explore Optimizely CMS's latest roadmap, packed with developer-focused updates. From SaaS speed to Visual Builder enhancements, developer tooling...

Andy Blyth | Nov 21, 2024 | Syndicated blog

Set Default Culture in Optimizely CMS 12

Take control over culture-specific operations like date and time formatting.

Tomas Hensrud Gulla | Nov 15, 2024 | Syndicated blog

I'm running Optimizely CMS on .NET 9!

It works 🎉

Tomas Hensrud Gulla | Nov 12, 2024 | Syndicated blog

Recraft's image generation with AI-Assistant for Optimizely

Recraft V3 model is outperforming all other models in the image generation space and we are happy to share: Recraft's new model is now available fo...

Luc Gosso (MVP) | Nov 8, 2024 | Syndicated blog