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".
Comments