Handling Huge Number of Article Pages using Date Structured Container Pages in EPiServer
I have been inspired by some interesting post about how to handle large amounts of pages in EPiServer such as this one, this other one, and last but not the least this one. All of them point to use a more structured site, sometimes using categories and more often using the current date as part of the structure. Of course when we add pages below any other page the Url will keep that structure. So for instance, if you have an article detail page under article index – 2018 – 07 – 09 you will have an url similar to:
Sometimes this is enough, but usually you want to avoid this kind of urls and have something cleaner such as:
Fortunately to allow this kind of behavior you can use partial routing from EPiServer (You can find the official documentation over here).
Now, you will be wondering, but those container pages are still normal pages so how can we make them look and feel like real container pages/ folders. Well, we will have to override their default template coordinator and if you want also change their ui descriptor to show a differentiated icon.
Finally, maybe you want to add an article detail page inside the article index page but you want to automatically generate the folder structure when the article detail page is created. We can achieve this by hooking up to the creating content event and move the item to the respective parent.
Having all these, we can have a basic solution which will a achieve high performance site with a well defined structure for pages which you think will be created tons of times such as articles or posts.
In this post, we will begin with the core classes to allow this kind of behavior. This means, create container pages with their respective ui descriptors, a template coordinator and its initialization module, the article index and article detail pages, a search helper powered by Find, and after that, we will go forward with the creation of the article partial router and article creation configuration.
So, lets begin !!!!
First, we will create two empty interfaces which will represent the container pages.
namespace Example.Data.Interfaces.Page
{
public interface IContainerPage
{
}
}
namespace Example.Data.Interfaces.Page
{
public interface INoRenderPage
{
}
}
Then, we will create a template coordinator which will specify that if a page has the interface IContainerPage or INoRenderPage it will not have a default template.
namespace Example.Business.Rendering
{
using EPiServer.ServiceLocation;
using EPiServer.Web;
using EPiServer.Web.Mvc;
using Data.Interfaces.Page;
[ServiceConfiguration(typeof(IViewTemplateModelRegistrator))]
public class TemplateCoordinator : IViewTemplateModelRegistrator
{
public static void OnTemplateResolved(object sender, TemplateResolverEventArgs args)
{
//Disable DefaultPageController for page types that shouldn't have any renderer as pages
if ((args.ItemToRender is IContainerPage || args.ItemToRender is INoRenderPage)
&& args.SelectedTemplate != null)
{
args.SelectedTemplate = null;
}
}
public void Register(TemplateModelCollection viewTemplateModelRegistrator)
{
}
}
}
Now, we need to add an initialization module which specifies that our custom template coordinator is going to replace the default one.
namespace Example.Business.Initialization
{
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using Rendering;
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class CustomizedRenderingInitialization : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
context.Locate.TemplateResolver()
.TemplateResolved += TemplateCoordinator.OnTemplateResolved;
}
public void Uninitialize(InitializationEngine context)
{
ServiceLocator.Current.GetInstance()
.TemplateResolved -= TemplateCoordinator.OnTemplateResolved;
}
public void Preload(string[] parameters)
{
}
}
}
Ok, so now we will create a container page which will be used to structure the site. This is a simple page with no attributes, the only important thing to notice is that implements the interface IContainerPage
namespace Example.Data.Models.Page
{
using EPiServer.DataAnnotations;
using EPiServer.Core;
using Interfaces.Page;
[ContentType(GUID = "117d93af-67a0-426d-abfc-bba3167bbfce")]
public class ContainerPage : PageData, IContainerPage
{
}
}
If you compile your code at this moment and you create a container page you will see that the button to see the properties of the page and the button to see the page in edit mode are not there.
Now, we will create a ui descriptor for the container page so we see a folder icon instead of the default one
namespace Example.Business.Descriptors
{
using EPiServer.Shell;
using Data.Interfaces.Page;
[UIDescriptorRegistration]
public class ContainerPageUIDescriptor : UIDescriptor
{
public ContainerPageUIDescriptor()
: base(ContentTypeCssClassNames.Folder)
{
DefaultView = CmsViewNames.AllPropertiesView;
}
}
}
[/code]
This will show a container page in the tree structure like this:
Now, we will create the article detail and article index pages which are pages with some basic attributes, nothing special here.
namespace Example.Data.Models.Page
{
using EPiServer.Core;
using EPiServer.DataAbstraction;
using System.ComponentModel.DataAnnotations;
using EPiServer.DataAnnotations;
[ContentType(GUID = "5c2a6ae1-6cc0-408a-aabf-effe7e84665a")]
public class ArticleIndexPage : PageData
{
[Display(GroupName = SystemTabNames.Content, Order = 100)]
public virtual ContentArea ArticleFilters { get; set; }
[Display(GroupName = SystemTabNames.Content, Order = 200)]
public virtual ContentArea ArticleGrid { get; set; }
}
}
namespace Example.Data.Models.Page
{
using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.DataAnnotations;
using System.ComponentModel.DataAnnotations;
[ContentType(GUID = "5c2a6ae1-6cc0-408a-a72f-effe7e8ba65a")]
public class ArticleDetailPage : PageData
{
[Display(GroupName = SystemTabNames.Content, Order = 100)]
public virtual string ArticleTitle { get; set; }
[Display(GroupName = SystemTabNames.Content, Order = 200)]
public virtual XhtmlString ArticleContent { get; set; }
}
}
To finish the core classes, we will generate a search helper class using FIND which will allow to find an article detail page easily. This part of the code can be changed to follow the logic of your site, but it is highly recommended to use Find instead of FindPagesWithCriteria if you want to achieve the best performance possible.
namespace Example.Business.Helpers
{
using EPiServer.Find;
using EPiServer.Find.Cms;
using EPiServer.Find.Framework;
using System.Linq;
using Data.Models.Page;
public static class SearchHelper
{
public static IClient Client => SearchClient.Instance;
public static ArticleDetailPage GetArticle(string name)
{
var query = SearchClient.Instance.Search();
query = query.Filter(x => x.URLSegment.Match(name));
return query.GetContentResult().FirstOrDefault();
}
}
}
Having all the core classes, we will move forward to the article partial router and the article creation configuration. These two classes are a little bit more complex, so I will try to explain them in more detail.
First, we have the article partial router class. The class asks for two parameters. To visually those parameters better we can use the following image
In this example, the first parameter is the article-index part of the url which represents an Article Index page, the second parameter is the rest of the url that will be used to find an specific page, in this case, we want to find an article detail page. So our partial router class will have the article index page type as first parameter and the article detail page as second.
The router partial method is in charge to process the url who matches the specified base type (Article index page) and try to return the second parameter, an article detail page based on the rest of the url, in the image above article-detail-3 will be the identifier that will help us to find the page inside the CMS. If we find a page with the specified identifier, we will replace the remaining path property of the segment context object with what is left (maybe a query string?) and set the route content link property of the same object to the article detail page content link. If we cannot find the page we just return null.
The get partial virtual path method on the other hand, returns the url that we want when we invoke helpers like UrlResolver.Current.GetUrl(currentPage), so every time we want to display the link to an article detail page, it will display the cleaner url instead of the one with the container folders. To achieve this, we start from the article detail page and go up until we found the article index page, it can go up to 10 levels and if does not find the article index page it will not modify the date structured url. This code is for sure not the best implementation for this scenario. So you are more than welcome to improve it.
namespace Example.Business.Util
{
using System.Web;
using System.Web.Routing;
using EPiServer;
using EPiServer.Core;
using EPiServer.Editor;
using EPiServer.ServiceLocation;
using EPiServer.Web.Routing;
using EPiServer.Web.Routing.Segments;
using Helpers;
using Data.Models.Page;
public class ArticleRouter : IPartialRouter
{
private readonly Injected Repository;
public object RoutePartial(ArticleIndexPage content, SegmentContext segmentContext)
{
if (content.ContentLink.ID == 0)
{
return null;
}
var nextSegment = segmentContext.GetNextValue(segmentContext.RemainingPath);
var urlSegment = nextSegment.Next;
if (string.IsNullOrEmpty(urlSegment))
{
return null;
}
var articleName = HttpUtility.UrlDecode(urlSegment);
var article = SearchHelper.GetArticle(articleName);
if (article == null)
{
return null;
}
segmentContext.RemainingPath = nextSegment.Remaining;
segmentContext.RoutedContentLink = article.ContentLink;
return article;
}
public PartialRouteData GetPartialVirtualPath(
ArticleDetailPage content,
string language,
RouteValueDictionary routeValues,
RequestContext requestContext)
{
var contentLink = requestContext.GetRouteValue("node", routeValues) as ContentReference;
if (!content.ContentLink.CompareToIgnoreWorkID(contentLink))
{
return null;
}
if (PageEditing.PageIsInEditMode)
{
return null;
}
ContentReference parentContentReference = ContentReference.StartPage;
var maxDepth = 10;
var currentParent = content.ParentLink;
var count = 0;
while (count string.Compare(f.Name, folderName, StringComparison.OrdinalIgnoreCase) == 0);
if (storedFolderPage != null)
{
return storedFolderPage;
}
storedFolderPage = Repository.GetDefault(parentFolder, languageSelector.Language);
storedFolderPage.Name = folderName;
Repository.Save(storedFolderPage, SaveAction.Publish, AccessLevel.NoAccess);
return storedFolderPage;
}
}
}
Finally, we will create an initialization module which will hook up to the creating content event, and if is an article detail page below an article index page it will create the necessary folder structure based on the current date and move the current article detail page to the corresponding folder.
namespace Example.Business.Initialization
{
using System;
using System.Linq;
using EPiServer;
using EPiServer.Core;
using EPiServer.DataAccess;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.Globalization;
using EPiServer.Security;
using EPiServer.ServiceLocation;
using Example.Data.Models.Page;
[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ArticleCreationConfig : IInitializableModule
{
public IContentRepository Repository;
private IContentEvents _contentEvents;
public void Initialize(InitializationEngine context)
{
//Add initialization logic, this method is called once after CMS has been initialized
if (_contentEvents == null)
{
_contentEvents = ServiceLocator.Current.GetInstance();
}
if (Repository == null)
{
Repository = ServiceLocator.Current.GetInstance();
}
_contentEvents.CreatingContent += ContentEvents_CreatingContent;
}
public void Uninitialize(InitializationEngine context)
{
//Add uninitialization logic
if (_contentEvents == null)
{
_contentEvents = ServiceLocator.Current.GetInstance();
}
_contentEvents.CreatingContent -= ContentEvents_CreatingContent;
}
private void ContentEvents_CreatingContent(object sender, ContentEventArgs e)
{
if (sender == null || e == null) return;
if (e.Content is ArticleDetailPage)
{
var parentLink = e.Content.ParentLink;
var articleIndex = Repository.TryGet(parentLink, out ArticleIndexPage content) ? content : null;
if (articleIndex == null)
{
var errorMessage = "Article detail page can only be created below an article index page";
e.CancelAction = true;
e.CancelReason = errorMessage;
}
else
{
e.Content.ParentLink = GetNewParent(parentLink);
}
}
}
private ContentReference GetNewParent(ContentReference parentLink)
{
var yearFolder = GetOrCreateContainerFolder(parentLink, DateTime.Now.Year.ToString(), string.Empty);
if (yearFolder != null)
{
var monthFolder = GetOrCreateContainerFolder(yearFolder.ContentLink, DateTime.Now.ToString("MM"), string.Empty);
if (monthFolder != null)
{
var dayFolder = GetOrCreateContainerFolder(monthFolder.ContentLink, DateTime.Now.ToString("dd"), string.Empty);
if (dayFolder != null)
{
return dayFolder.ContentLink;
}
}
}
return parentLink;
}
private ContainerPage GetOrCreateContainerFolder(ContentReference parentFolder, string folderName, string languageId)
{
var currentCulture = ContentLanguage.PreferredCulture;
var languageBranch = string.IsNullOrWhiteSpace(languageId) ? currentCulture.Name : languageId;
var languageSelector = new LanguageSelector(languageBranch);
var storedFolderPage = Repository.GetChildren(parentFolder, languageSelector)
.FirstOrDefault(f => string.Compare(f.Name, folderName, StringComparison.OrdinalIgnoreCase) == 0);
if (storedFolderPage != null)
{
return storedFolderPage;
}
storedFolderPage = Repository.GetDefault(parentFolder, languageSelector.Language);
storedFolderPage.Name = folderName;
Repository.Save(storedFolderPage, SaveAction.Publish, AccessLevel.NoAccess);
return storedFolderPage;
}
}
}
And that is all. Now, every time you create an article detail page inside an article index page it will be moved to the container folder based on the current date. If is not created inside an article index page it will display an error saying that the page cannot be created outside of the article index page, and finally the url in the site will be reflected as it is below the article index page, instead of the date structured folders. I hope it will help someone and as always keep learning !!!
Leave a Reply