back to all opinions

Advanced Content Replication in Sitecore

Dec 17, 2019 - 10 minutes read

One of the last projects I was working on had an interesting request. A request that I’ve seen all too often on Sitecore sites even though we don’t have a fitting solution out of the box. Namely the wish to have content managed in one place but have it appeared (the same) on multiple other places. Those other places can range from other websites on the same platform to specific subfolders in one website.

Luckily the project was built using SXA and the client’s request was clear: Create news pages globally and have them appear on selected websites. Any changes that happened to a news page in the global node should update all the local versions immediately and the local versions won’t have to be editable. So, it became time to devise a solution. And the solution was already built into Sitecore, namely Cloning.

Filip Verswijver

Solution Architect at Boondoggle

the gist

When your content has a bigger purpose than just one website, it should be easy to manage. We show you how.

For those who don’t know it, cloning in Sitecore allows you to create a “virtual” item, linked to an original item and content is synced from the original to the virtual. In “the olden days” the user would have to manually accept the changes to their virtual items, but nowadays there are more ways to automatically accept these changes, even SXA has this out of the box.

Then there’s the second part of the request, namely to be able to select websites where the content should appear. This would also mean that the exact parent item should be selected per website. And why not make it more flexible so a content editor can add multiple possible locations per website?
Mappings would be needed for this, something easily configured – even by a content editor.

Configuration is key

Because I think reusability and flexibility are key on a request like this (it’s best not to explain to clients that the addon was written for them only supports what they constitute as an edge case, while they would want to use it for so much more) having a way to configure all the settings while leaving room for improvements is key.

The best way I saw this happening meant extending Sitecore with an additional field type.

Why? Well, I want to have a lot of things depending on the site (it’s a Site Mapping after all) and make it error-proof. That means, that when a user selects a website, he should then select a destination for the cloned items to be cloned under. But I also don’t just want a tree list of the entire tenant, allowing them to make a mistake.

That’s why I decided to create a new field type called LinkFieldDependentTreelist (yes, I know it’s a mouthful, but it fits the bill). This new type of tree list takes 2 parameters (to be set in the Source field, as per usual):

  • FieldName
    This should refer to the name of a field (also present on the item) which links to an item (in this case a Droplink field)
  • SubFilter
    At this point this isn’t being used yet but why not think ahead and include this anyway? It would take an additional path/query filter that gets applied to the item chosen in the other field

How does it work?

It works quite simple actually, you select an item in your drop link (in this case the Site field) and the tree list field is automatically updated (on save) with a data source of your selected item + the SubFilter (if present).
Example:
If the selected item is /Sitecore/content/Home and the SubFilter is empty, the source would be query:/sitecore/content/Home, if a SubFilter is defined it could look like this: query:/sitecore/content/Home/Presentation/Page Designs

The magic of the LinkFieldDependentTreeList

private string CalculateNewSource()
{
    var currentItem = Sitecore.Context.ContentDatabase.GetItem(ItemID);
    if (currentItem == null)
    {
        return null;
    }

    var selectedFieldValue = currentItem[FieldName];
    if (Sitecore.Data.ID.TryParse(selectedFieldValue, out Sitecore.Data.ID selectedId))
    {
        var selectedItem = Sitecore.Context.ContentDatabase.GetItem(selectedId);
        if (selectedItem == null)
        {
            return null;
        }

        HasModifiedSource = true;
        return $"query:{selectedItem.Paths.FullPath}{SubFilter}";
    }

    return null;
}

This way a content editor can create a mapping where they select a site and the other field(s) are automatically populated with the correct information – no mistakes possible and very straightforward.

Site mapping

Extension is the goal

Sitecore, by default, is very extensible. It uses a plethora of pipelines and events, each of which can be injected into, default behavior modified, … When I develop something rather generic, I always try to adhere to the same principles (and it also helps with keeping everything within the Helix guidelines).
That’s why I’ve created 3 additional pipelines:

  • contentReplicationCloneItems
    This is the beating heart of advanced content replication. It has processors that:
    • Validate the passed data (and return a properly formatted error to warn content editors, don’t leave them in the dark or force them to read log files!)
    • Get the destination item based on the mappings available. It’s all contextual, so there can be multiple content replication nodes, each with their own mappings.
    • Get an existing clone item if available (to prevent cloning the same item again underneath the same root item)
    • Clone the item.
    • Create a correct folder structure. More about that later as it warrants its own topic.
    • Move the cloned item to the correct folder.
    • Protect the item, because we want to make sure that the content editor won’t accidentally modify the cloned item instead of the original one.
  • contentReplicationPostClone
    Some things we call after the cloning itself because I noticed that cloning triggers some actions within Sitecore, and by splitting these steps off from the main pipeline I’m ensuring these actions have time to complete successfully.
    • Remove other language versions. The original item will possibly have more languages than the site itself allows. That’s why the site dictates the available languages (on the site node itself) and this processor will remove all language from the cloned item that isn’t allowed for this website.
  • contentReplicationUncloneItems
    When a content editor removes a site from the original item’s selection the clones from that site should also be removed. The steps taken for this are:
    • Get all clones for the item
    • Filter out the clones that we still need to keep (so for all the sites that are selected, if the clone appears underneath a website that isn’t selected it’s kept for removal).
    • Remove all clones that haven’t been filtered out in the processor above.

The configuration that ties it all together

<pipelines>
    <contentReplicationCloneItems>
        <processor type="Foundation.ContentReplication.Pipelines.CloneItems.ValidateArgumentValues, Foundation.ContentReplication" />
        <processor type="Foundation.ContentReplication.Pipelines.CloneItems.GetDestinationRootItem, Foundation.ContentReplication" resolve="true" />
        <processor type="Foundation.ContentReplication.Pipelines.CloneItems.GetPageDesignItem, Foundation.ContentReplication" resolve="true" />
        <processor type="Foundation.ContentReplication.Pipelines.Base.GetExistingCloneItem, Foundation.ContentReplication" />
        <processor type="Foundation.ContentReplication.Pipelines.CloneItems.CloneItem, Foundation.ContentReplication" />
        <processor type="Foundation.ContentReplication.Pipelines.CloneItems.CreateCorrectFolderItem, Foundation.ContentReplication" resolve="true" />
        <processor type="Foundation.ContentReplication.Pipelines.CloneItems.MoveClonedItemToDestinationItem, Foundation.ContentReplication" />
        <processor type="Foundation.ContentReplication.Pipelines.CloneItems.ApplyPageDesign, Foundation.ContentReplication" resolve="true" />
        <processor type="Foundation.ContentReplication.Pipelines.CloneItems.ProtectItems, Foundation.ContentReplication" />
    </contentReplicationCloneItems>

    <contentReplicationPostClone>
        <processor type="Foundation.ContentReplication.Pipelines.Base.GetExistingCloneItem, Foundation.ContentReplication" />
        <processor type="Foundation.ContentReplication.Pipelines.PostClone.RemoveOtherLanguageVersions, Foundation.ContentReplication" />
    </contentReplicationPostClone>

    <contentReplicationUncloneItems>
        <processor type="Foundation.ContentReplication.Pipelines.UnCloneItems.GetAllClonesForItem, Foundation.ContentReplication" />
        <processor type="Foundation.ContentReplication.Pipelines.UnCloneItems.FilterLeftoverClones, Foundation.ContentReplication" />
        <processor type="Foundation.ContentReplication.Pipelines.UnCloneItems.RemoveClones, Foundation.ContentReplication" />
    </contentReplicationUncloneItems>
</pipelines>

While this may seem like a handful, it’s all relatively easy. Every processor only has limited responsibility and validates all the data needed to perform its task. This way it’s easy to add additional steps if needed, making it flexible by compartmentalizing code instead of creating one big service that does all of this.

Making the specifics work

The initial request/use case was for managing news items. So, this needs to work, specifically for those templates. Because it’s Helix, I just create a new event handler in my News feature, add the necessary code to prevent it from over-triggering and then the magic happens.

I fill in the necessary arguments for the pipeline I created in my foundation and then run CorePipeline.Run. After that, I catch any errors that might’ve occurred (remember how I said that we return properly formatted errors?
Now we need to show them to a content editor). I just create a new SheerResponse that specifically renders any and all error messages and done.

Running the pipeline and showing proper error messages

CorePipeline.Run("contentReplicationCloneItems", pipelineArgs);

if (pipelineArgs.Aborted)
{
    SheerResponse.ShowError(
        $"Unable to clone to {site.Name}",
        pipelineArgs.GetMessages()
            .Select(message => message.Text)
            .Aggregate((current, next) => $"{current}\r\n{next}")
    );

    continue;
}

Folders, folders everywhere

As mentioned before, I create a correct folder structure for each cloned item. It’s generic in that you can determine the folder structure based on the parameters sent through the pipeline. In this case, I only implemented what I call DateBasedArgs as for news items I want a clear folder structure based on the news item’s date.
So, each news item has a date and the folder structure should look like this:
If the news item’s date is December 31st, 2019 the folder structure would be: <root path as given in the mapping>/2019/12/31/<news item name>

And to keep things in sync I will do the same with the global news item when saved create a new folder and move it.
So, it’s easier for a content editor to find and to manage (and we work around that pesky performance problem with +100 items per node in Sitecore).

SXA Specials

This solution was built on top of SXA. Not that it won’t work without SXA as most features are purely Sitecore basics, but we are leaning onto some things SXA has built to make cloning easier for content editors, their multi-site solution and… their page designs!

Later, I discovered that of course, since we’re mapping to multiple websites we should adhere to that website’s styling. Quite easy to do with SXA as each specific page type has its own page design so it’s just a matter of setting the correct page design on every clone. And we already have site mappings that determine where each clone should be cloned under. So why not extend that mapping?

It’s as easy as adding a new field to the mapping template, reusing the Link Field Dependent Treelist and specifying the SubFilter parameter to only show the page designs. Now a content editor can choose the correct design per site.

Next step is plugging that into the pipelines, again easy to do: just 2 processors are needed in the contentReplicationCloneItems pipeline:

  • One to get the page design item from the mapping.
  • One to apply the page design to the cloned item.

Determining the correct page design

public class GetPageDesignItem : BaseContentReplicationPipeline<CloneItemArgs>
{
    private readonly IDesignItemService designItemService;

    public GetPageDesignItem(IDesignItemService designItemService)
    {
        this.designItemService = designItemService;
    }

    public override void Process(CloneItemArgs args)
    {
        var pageDesignItem = designItemService.GetPageDesign(args.SourceItem, args.SiteItem);
        if (pageDesignItem == null)
        {
            Logger.Warn(GetType(), "Unable to find page design item - aborting pipeline");

            args.AddMessage("Unable to find page design item, make sure it is defined in the mapping", Sitecore.Pipelines.PipelineMessageType.Warning);
            args.AbortPipeline();

            return;
        }

        args.PageDesignItem = pageDesignItem;
    }
}

Applying the correct page design

public class ApplyPageDesign : BaseContentReplicationPipeline<CloneItemArgs>
{
    private readonly IDesignItemService designItemService;

    public ApplyPageDesign(IDesignItemService designItemService)
    {
        this.designItemService = designItemService;
    }

    public override void Process(CloneItemArgs args)
    {
        args.ClonedItem = designItemService.ApplyDesign(args.ClonedItem, args.PageDesignItem);
    }
}

Done! And the nice thing is because I set up the pipeline to also run on existing clone items (if one is found) that if the page design mapping changed those changes are immediately applied on save of the original item.

Gotchas

No project is ever without things you stumble upon while building it. The page design was one of those, but another was that the event listener for events is on save. And I also move the original item to a new folder. Which triggers a save…

So yes, as you can see there’s a bit of recursion here where a save event will be triggered twice. Of course, I don’t want this, and it’s easy to circumvent. There are already built-in event disablers in Sitecore but for this, I want a specific Content Replication event disabler.

It's just an easy class implementing Sitecore’s Switcher and EventDisablerState and exposing a variable IsActive. I then check if the event is active if so, I skip the event handler, and if not, I continue onto the rest of the code. That code is then wrapped with the event disabler which in turn enables the flag that’s checked earlier.

A simple (but effective) event disabler

public class ContentReplicationEventDisabler : Switcher<EventDisablerState, ContentReplicationEventDisabler>
{
    public ContentReplicationEventDisabler() : base(EventDisablerState.Enabled) { }

    public static bool IsActive
    {
        get
        {
            return CurrentValue == EventDisablerState.Enabled;
        }
    }
}

Success?

Did I reach the goals set out?

  • Is it easy to use for a content editor?
    Yes, it is. Once the mapping is set up (which can be done by a content editor, content administrator, platform manager, …) a content editor just must create a new item in the global node, edit the page as per usual (Experience Editor or Content Editor, both will work) and choose the correct websites.
    When he then clicks save all the magic is triggered and the items are created. No need to manage the content locally, no need to copy-paste entire word documents into multiple items, it just works.
  • Is it extensible?
    As I proved with the extensions I needed to implement later, yes. It’s easy to add or modify behavior (which is what all developers want).
  • Is it reusable?
    Yes and no. It’s not a drag and drop procedure if you want to use it for something other than news items. But because it’s so extensible it’s easy. Even if you need really need to change the base behavior it’s as easy as creating a new pipeline with only the processors you need, maybe creating a new argument class and implement your changes.