Monday, January 25, 2021

SXA cache multilingual datasources

Sitecore SXA datasource caching

I bumped into an issue a while ago related to datasources and how they are handled in a Sitecore SXA environment. This is an overview of our setup:

  • using SXA (Sitecore Experience Accellerator) for Sitecore 9.2
  • a PageList component with a variant
  • an Item Query that refers to custom code (a query starting with "code:" - more info here)
  • custom code that return a list of items and is language dependent (meaning: the list of items is different per language)  

That last one is actually the trigger for my issue as I had used custom coded Item Queries before and they seemed to work fine. Even in a multilingual setup. But in this particular case the results per language were actually different items - not just the same items with translated content.

I noticed that there was some caching issue and in the end I contacted Sitecore Support and placed some information on Sitecore StackExchange: "Custom SXA PageList datasource is not working in multilingual environment".


ResolveRenderingDatasourceCache

With some research I found there was an issue with the ResolveRenderingDatasourceCache. That cache is not language dependent and so my results were wrong.

A first possible solution would be to disable that cache by patching the config setting XA.Foundation.LocalDatasources.ResolveRenderingDatasourceCache.Enabled to false. This works, but of course that turns off this cache completely.

I noticed that the resolveRenderingDatasource pipeline has 2 relevant functions:
  • Sitecore.XA.Foundation.LocalDatasources.Pipelines.ResolveRenderingDatasource.GetFromCache
  • Sitecore.XA.Foundation.LocalDatasources.Pipelines.ResolveRenderingDatasource.SetCache
This brings us to solution two, which would disable the cache only for datasources that start with "code:": we can override the process method in the SetCache class and add code like this:
if (args.DefaultDatasource.StartsWith("code")) { return; }
This works as well, we keep the cache for most of our datasources but it is still disabled for the coded ones. And so I thought of another final solution: let's check the generation of the cache key!

BuildCacheKey

Both methods (GetFromCache and SetCache) are using a BuildCacheKey method from an underlying ResolveRenderingDatasourceCacheBase class. This method seemed not to be used anywhere else so I decided to override that one and use it in both the GetFromCache and SetCache classes.
protected override string BuildCacheKey(ResolveRenderingDatasourceArgs args)
{
    var key = base.BuildCacheKey(args);
    if (!string.IsNullOrEmpty(key) && args.DefaultDatasource.StartsWith("code:", StringComparison.OrdinalIgnoreCase))
    {
        key += Sitecore.Context.Language.Name;
    }

    return key;
}
This solution makes the cache work in all situations. We keep the current implementation for most datasources (as those are not language dependent) and add the language to the key for those that might (the ones starting with "code:" and you might also add those starting with "query:" if needed).
 
Patch the processors as:
<sitecore>
  <pipelines>
    <resolveRenderingDatasource>
      <processor type="Sitecore.XA.Foundation.LocalDatasources.Pipelines.ResolveRenderingDatasource.GetFromCache, Sitecore.XA.Foundation.LocalDatasources" resolve="true">
        <patch:attribute name="type">Feature.Caching.Rendering.Datasources.GetFromCache, Feature.Caching</patch:attribute>
      </processor>
      <processor type="Sitecore.XA.Foundation.LocalDatasources.Pipelines.ResolveRenderingDatasource.SetCache, Sitecore.XA.Foundation.LocalDatasources" resolve="true">
        <patch:attribute name="type">Feature.Caching.Rendering.Datasources.SetCache, Feature.Caching</patch:attribute>
      </processor>
    </resolveRenderingDatasource>
  </pipelines>
</sitecore>

A change request has been made to adjust this in a future version of SXA - but in the meantime you can easily patch it yourself if needed. Keep the cache and enable different results in different languages!


 

Thursday, January 21, 2021

Sharing available renderings in multisite SXA

Sitecore SXA available renderings

We have a multisite setup in SXA to easily create (small) websites and have been sharing a lot of configuration, layout information and content between those. Check my latest SXA blog posts for more information.

We did already cover sharing placeholder settings. But if we can share those, we might as well share the available renderings configuration. This is a list of renderings that is defined per site to define the availability within the SXA Toolbox - and since SXA 1.8 we can also define the structure of the toolbox with these Available renderings. As explained in the documentation, you have to check the box "Group renderings in sections according to Available Renderings items in the site" to do so.


I believe it is a best practice to do this so that feature is always on in my projects.

But let's get back to sharing...  We have a shared site (and master site) setup, so it would be a good idea to define the available renderings in the shared site and not in every site (created from the master site). This idea was on my radar and I got triggered by Toby Gutierrez to really try this now.


Sharing available renderings

I am going to try the same principle I used for the placeholder settings: if the current site has no configuration in place -meaning no children underneath the available renderings parent node- we check the shared site(s) and get the configuration there.

The toolbox

First of all, we need to figure out how the toolbox works. There is a handler (sxa-toolbox-feed.ashx) but that is not to much use for us here as it works with data that is retrieved elsewhere. I noticed however that in case the renderings grouping is turned on, a AvailableRenderingsOrderingService is called. This service will read the configuration and use this to structure the toolbox data that was passed along. As a side affect, it will also filter (again) on the available renderings.

This means that if you are using the grouping -as I do- you will have to override this. Let's do that:
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.DependencyInjection;
using Sitecore.XA.Foundation.Editing.Models;
using Sitecore.XA.Foundation.Multisite;
using Sitecore.XA.Foundation.Presentation;
using Sitecore.XA.Foundation.SitecoreExtensions.Extensions;
using Sitecore.XA.Foundation.SitecoreExtensions.Repositories;

namespace Feature.Multisite.Editing
{
  public class AvailableRenderingsOrderingService : Sitecore.XA.Foundation.Editing.Service.AvailableRenderingsOrderingService
  {
    public AvailableRenderingsOrderingService(
      IPresentationContext presentationContext,
      IMultisiteContext multisiteContext,
      IContentRepository contentRepository) : base(presentationContext, multisiteContext, contentRepository)
    {
    }

    public override IList<AvailableRenderingEntry> GetOrderedRenderings(Item siteItem, IList<Item> renderings = null)
    {
      var orderDictionary = new Dictionary<ID, ValueTuple<int, string>>();
      var presentationItem = PresentationContext.GetPresentationItem(siteItem);
      var folder = presentationItem?.FirstChildInheritingFrom(Sitecore.XA.Foundation.Presentation.Templates.AvailableRenderingsFolder.ID);
      if (folder != null)
      {
        if (!folder.Children.Any())
        {
          var shared = ServiceLocator.ServiceProvider.GetService<ISharedSitesContext>().GetSharedSitesWithoutCurrent(siteItem);
          foreach (var site in shared)
          {
            presentationItem = PresentationContext.GetPresentationItem(site);
            folder = presentationItem?.FirstChildInheritingFrom(Sitecore.XA.Foundation.Presentation.Templates.AvailableRenderingsFolder.ID);
            if (folder != null && folder.Children.Any())
            {
              break;
            }
          }
        }

        if (folder != null)
        {
          var sections = (CheckboxField)folder.Fields[Sitecore.XA.Foundation.Presentation.Templates.AvailableRenderingsFolder.Fields.GroupRenderingsInSections];
          if (sections != null && sections.Checked)
          {
            var array = folder.Children.Where(item => !string.IsNullOrEmpty(item[Sitecore.XA.Foundation.Presentation.Templates._RenderingsList.Fields.Renderings])).ToArray();
            for (var index1 = 0; index1 < array.Length; ++index1)
            {
              var fieldRenderings = (MultilistField)array[index1].Fields[Sitecore.XA.Foundation.Presentation.Templates._RenderingsList.Fields.Renderings];
              for (var index2 = 0; index2 < fieldRenderings.TargetIDs.Length; ++index2)
              {
                if (!orderDictionary.ContainsKey(fieldRenderings.TargetIDs[index2]))
                {
                  orderDictionary.Add(fieldRenderings.TargetIDs[index2], new ValueTuple<int, string>((index1 + 1) * 1000 + index2, fieldRenderings.InnerField.Item.DisplayName));
                }
              }
            }
          }
        }
      }

      return renderings?.Select(r => new AvailableRenderingEntry
      {
        RenderingItem = r,
        Order = orderDictionary.ContainsKey(r.ID) ? orderDictionary[r.ID].Item1 : 0,
        SectionName = orderDictionary.ContainsKey(r.ID) ? orderDictionary[r.ID].Item2 : r.Parent.DisplayName
      }).OrderBy(r => r.Order).ToList();
    }
  }
}  
This code is almost identical as the original one. The difference is in the GetOrderedRenderings function where we check the existance of a local configuration (folder.Children.Any) and try the shared sites (using the SharedSitesContext) if needed.

The service is registered in the config, so we patch that config to include our version:
<sitecore>
  <services>
    <register serviceType="Sitecore.XA.Foundation.Editing.Service.IAvailableRenderingsOrderingService, Sitecore.XA.Foundation.Editing"
              implementationType="Sitecore.XA.Foundation.Editing.Service.AvailableRenderingsOrderingService, Sitecore.XA.Foundation.Editing" lifetime="Singleton">
      <patch:attribute name="implementationType">Feature.Multisite.Editing.AvailableRenderingsOrderingService, Feature.Multisite</patch:attribute>
    </register>
  </services>
</sitecore>

That's it. Now we can safely delete all children from the Available renderings node in our sites that use our shared site. The toolbox in those sites will use the configuration defined in the shared site.


But...

Note that this only works if the grouping is enabled. As I always do this, I'm not going to try to implement a solution if it's not. This also means that the toolbox handler might be getting too much renderings as a parameter, but the result is ok so that's fine for me. 

However, if you do require such a solution without the grouping you'll need to check the getPlaceholderRenderings pipeline and especially the GetModuleRenderings processor. This has a function called GetSiteRenderingSources and you'll have to add the same logic there as I used in the AvailableRenderingsOrderingService.


Conclusion

Yet another configuration that we can share across all the created sites...  this makes it easier to handle if we add/remove components that can be used on those sites - we only have to do this on one spot from now on.


Monday, January 11, 2021

Sitecore personalization with device detection

Sitecore device detection personalization rules

I had a request from a content editor to add a different (large) image to a page depending on the device - on smaller screens it had to be a completely different image that was better suited. So the normal approach of a responsive image didn't work here as the image source was different and not just the size.



A first test was made by using the grid options provided by SXA. This way the editor can add both images and select when they should appear by setting the visibility as none on the unwanted devices per image.

Although this works on first sight, there is something that made us not use this - browsers will load both images even if they are marked not to be displayed by css. As we are trying to find a solution to have better mobile experience, this is not what we want.


Sitecore personalization - "where device type is value"

Out of the box Sitecore has some personalization rules that work with the device detection. As mentioned in the documentation we can use this to set up personalization targeting users differently based on their device. Many rules are available and documented. The screen size rules might seem an option, but those are not very easy to configure to detect what we wanted. A better suited rule would be the check on device type: "where device type is value". 

But.. although it is still in the documentation and screenshots, we couldn't find that rule. With the Sitecore search we did find it - in a section with obsolete rules :(  There is another -very similar- rule now though: "where device type is one of". This does almost the same but you can give a list of device to match - which seems even better! 

When testing the rule we found something weird though.


Where device type is one of

I created a page with a simple component and two datasources, one with the desktop image and one with the mobile image. When we tested this it worked once, but most of the time it didn't. We were using the Chrome developer tools device simulator as we didn't have access to the test environment with a mobile phone. I started the investigation with a look in the code.   

I started by checking the DeviceTypeCondition in the CES assembly, but that is marked obsolete (just as the item in Sitecore) - we should check Sitecore.ContentTesting.Rules.Conditions.DeviceTypeCondition instead. 

This new version of the rule has some twists however that seemed weird. When I debugged this code I noted that the cast from ruleContext to DeviceRuleContext always failed and in the RuleDeviceInformationManager this leads to a fallback mechanism - the getFallbackUserAgent pipeline.
DeviceRuleContext deviceRuleContext1 = ruleContext as DeviceRuleContext;
if (deviceRuleContext1 != null)
{
    ...
    string deviceRuleContext2 = this.GetUserAgentFromDeviceRuleContext(deviceRuleContext1);
    ...
    return ...
}
GetFallbackUserAgentArgs args = new GetFallbackUserAgentArgs();
GetFallbackUserAgentPipeline.Run(args);
When I checked the pipeline I found one processor: GetUserAgentFromTracker. And then it made sense to me...
The rule is getting the user agent from the tracker and not from the request - so the first call in the session determines the device.


Problem?

Although it is very weird that a piece of code always seems to go into what is called a fallback, is this actually a problem? Sitecore support thinks not - I think it is. I added a proposal to the Sitecore product ideas portal where you can upvote it if you agree...

So why is this a problem for me? My editors want to test their setup before going live. The site has a preview target where this can be accomplished but that preview target is (obviously) not publicly available. To properly test some content an editor would have to connect their mobile devices with a VPN or such and that is not that simple within some companies. It is much easier to use simulator tools like the one in Google Chrome. To use those however, one would have to clear all the site cookies before switching devices. This is possible... but with all due respect to editors, I think it wouldn't take that long before we get a request for help because the device rule is not working...

And what happens when there is no tracker? With all privacy regulations in place these days one cannot assume we do have a tracker.

The fix

We could create our own custom version of the rule - this works but we did bump into obsolete Sitecore code. It's an option - not sure whether it is a good one.

 Another option is to use the pipeline. Adding another processor before the tracker fallback will do the trick as well. For now I couldn't find any other usage except for the device rules so we might "fix" more than one...  

 Pipeline code

public class GetUserAgentFromContext : Sitecore.CES.DeviceDetection.Pipelines.GetFallbackUserAgent.GetFallbackUserAgentProcessor
{
    protected override string GetUserAgent()
    {
        return HttpContext.Current?.Request.UserAgent;
    }
}
<sitecore>
   <pipelines>
     <getFallbackUserAgent>
       <processor patch:before="*[1]" type="Feature....GetUserAgentFromContext, Feature..." />
     </getFallbackUserAgent>
   </pipelines>
</sitecore>
I've placed my processor first to keep the tracker as the fallback. If the context code found a useragent the other processors won't do anything anymore - if not, they will still do their job.




Monday, January 4, 2021

Sitecore media cache headers on pdf extension

MediaCache headers on PDF extension

MediaResponse.MaxAge

Based upon a recommendation from Google we set the MediaResponse.MaxAge setting to 365.00:00:00. This sets the Cache-Control header for media from the Sitecore MediaLibrary to "public, max-age=31536000", which is very good for images within the site. The header tells browsers that cached content younger than max-age seconds can be used without consulting the server- it should be used for content that doesn't change. 

But.. we also have other media types within the MediaLibrary - and today I am focusing on one particular type here being PDF documents. We wanted other cache settings for the pdf documents as we did run into a situation where a pdf file did change and only the internet-gods know where the old document was cached - it was not that easy to get rid off. So, I would like to be able to alter the cache settings for certain media types.


SXA and the MediaRequestHandler 

Note that I am using SXA. The Sitecore eXperience Accelerator makes our developer lives so much easier on various parts of a Sitecore development process, and again this is one of those occasions. If you have SXA, you have a Sitecore.XA.Foundation.MediaRequestHandler.MediaRequestHandler which overrides the default Sitecore.Resources.Media.MediaRequestHandler and this handler introduces 2 new pipelines. We could try to override this code again but it seems like a better option to check the pipelines. The first one is the  <mediaRequestHandler> pipeline and has some processors from SXA within. The second one is much more interesting for me here and is called <mediaRequestHeaders>. This pipeline is called just after setting the default media headers and can be used to alter them. By default it is empty (it doesn't even exist) - let's add a processor to it.

 MediaRequestHeaders pipeline

Adding a processor to the pipeline is rather easy:

<mediaRequestHeaders>
    <processor type="Feature.Caching.Pipelines.MediaRequestHeaderProcessor, Feature.Caching" />
</mediaRequestHeaders>
Now I just need to write some code for that processor:
public class MediaRequestHeaderProcessor
{
    public void Process(MediaRequestHeadersArgs args)
    {
        Assert.ArgumentNotNull(args, nameof(args));
        var media = args.Media;
        var cache = args.Context.Response.Cache;

        if (media.MimeType.StartsWith("application", StringComparison.OrdinalIgnoreCase))
        {
            cache.SetMaxAge(TimeSpan.FromMinutes(15));
            cache.SetCacheability(HttpCacheability.ServerAndPrivate);
            cache.AppendCacheExtension("no-cache");
        }
    }
}
The code provided here is an example of what it could be. You can do anything you want in here actually...  Our processor gets the media item and the Cache context from the provided arguments - we don't have to fetch any extra data here which is nice. We check the MimeType of the media item and act accordingly. Note again that we can implement any logic for any media item here, based on any type of information we can get from the media item (type, extension, …).

Using the cache context we can set all the header information we want. As an example in the code I am setting the max-age, the cacheability and I'm appending a custom extension. This will result in a cache header like "private, no-cache, max-age=900". 

Such a header makes the browser cache the result for 900 seconds, makes sure no intermediate (proxy) servers can cache it, and tells the browser not to trust a local version without a server agreement. The no-cache addition doesn't mean it can't be cached, it means a browser must check ("revalidate") with the server before using the cached resource. For our pdf documents, this sounds reasonable.


Conclusion

SXA made my life easier once again 😃

The extra mediaRequestHeaders pipeline made it very easy to include any business logic I want to alter cache headers that are being send with media requests.


Thanks to Sitecore StackExchange - as this answer from Richard helped me into the right direction.


Wednesday, November 18, 2020

Sitecore Symposium 2020

My 2 cents on The Virtual Edition

Note that I'm being optimistic and wrote "The" virtual edition instead of the "First" virtual symposium. Not that this virtual experience was all bad - certainly not. I saw some inspiring presentations, had a few good chats and I even found some benefits as well - no overlap in sessions making it easier to watch them all and... you can easily sneak out of the room if the session is not what you actually expected :)  I didn't get to enlarge my collections though:

This post is not an overview of the big announcements this year. Nor a list of highlights and most remarkable moments. I can safely make the assumptions that those will be covered already by others - Sitecore themselves, Adam Seabridge and others. I will just share some screenshots to show how the Sitecore interface might look like in the (near) future:


Still here? Good.. as I did want to write a small post about my 2020 Symposium experience. First of all, I did miss.. you! Yes, you. And you, and all the other members of our great Sitecore community. Before each symposium we take a look at the agenda and dream away when we read session titles that promise us great knowledge. But what we usually remember from each symposium are the moments outside the sessions - not just the fun (ok, yes, there is some fun..) but also the people. Getting to know the people within the Sitecore community and/or company. People who you might have helped already, or who you might help in their future Sitecore endeavors. And if you get stuck, they will probably help you as well.



Luckily we had some chats.. on Slack, on some virtual chat platform.. and on the second day we even found a breakfast Teams chat session with Sitecorians and a bunch of eager developers. And in these times, anything is better than nothing - it was good to hear other people thoughts on the announcements and evolutions.

Was it all so bad? No, it wasn't even bad! I'm glad I joined virtual Sym. And I did see some good sessions. Note that sessions are still available so you still have some time to catch up. Of course you have the keynotes, the sessions on containers with CaaS, Content Hubs and rendering hosts that you should (have) see(n) but we also got a lot of on-demand sessions to fill the rainy lockdown days. 

If you have an interest in SXA, you should watch the best practices session by Adam & Mark. And after that got you warmed up, continue by watching Una -always good for an interesting session- amplifying her SXA. As I skipped the marketing, business and commerce sessions I can't tell you about hidden gems in those areas but I do remember some nice stuff on Content Hub, on Web push notifications and I learned a few extra things on Solr, Azure and EXM. I'm not going to share more details or screenshots - I'm just giving ideas, you should watch them yourself ;)

Creating shorter sessions was a good idea - I really liked the 30' format and the on-demand sessions are usually even shorter. 

Symposium 2020 did deliver what I expected - I did learn some new things and got to interact a bit. The shorter sessions and on-demand videos to watch afterwards are good ideas and might also work in a non-virtual edition. We might evolve to a combination - take the best of both experiences, let people interact live and give some content for those whom can't travel. But nobody knows.. lets just hope we can all meet in Vegas next year 🤗




  

Monday, October 19, 2020

SXA tokens in a Sitecore Treelist datasource

Treelist datasource

A Treelist is a field type in Sitecore that is commonly used to select one or more items from a tree view. It is easy to use for editors and has a rather special way to set the root (datasource) for the tree that is available to those editors. 

A few related field types are available ootb (e.g. TreelistEx) and the Sitecore community has provided rather a few blogpost already on how to customize the Treelist field - and especially the way we set the datasource. 

The basics of how to set a Treelist datasource using the "datasource=" parameters syntax are described very well in this post from Mark Ursino. Or in the official Sitecore archives where setting items available for selecting within the TreeList field was posted already in 2009! Out of the box you are able to select a parent path and determine which templates can be shown and/or selected.

As I mentioned Sitecore community people have been posting blogs on how to extend the datasource based on several different requirements. 

SXA tokens

Sitecore eXperience Accelerator has a pipeline called resolveTokens. This pipeline is used to resolve tokens that can be used in queries and is (amongst other) useful to make templates multisite-friendly. 

The fields on your templates should have sources when applicable - i.e. when another item is selected. This applies to link type fields, list type fields, the image field...  Setting those sources is easy in a single site environment, but when you want to use those templates in a multisite setup you usually want the source to be relative to the site, the site's data folder... 

This is possible with the SXA tokens. You can set sources like "query:$site/..." and those will be resolved relatively within your sites. In a previous post I already showed how to extend this by creating a token for the real media root folder (to be able to add additional -lower- folders after the token).

This is a really nice way of working, but... it only applies to sources that start with "query:".

SXA tokens in a Treelist datasource

I do have a multisite setup. With SXA. And some of my templates do have Treelist fields. You can use the query notation in the datasource and use tokens that way, but then you loose the flexibility of the other parameters - so I was looking for a solution to have both. I want parameters to define the possible templates and I also want to use tokens to define the parent. 

My research led me to two blogposts that inspired me. Diego Moretto wrote about dynamic datasources in Treelist fields but his solution was for non-SXA and with the SXA tokens we do already have so much flexibility. He based his solution on a post from Kamruz Jaman that explains a solution on how to embed queries within the datasource syntax.

While reading that (really good) post I thought I had my solution. By implementing this you actually do have a source that (can) start with "query:" so the resolveTokens pipeline will run and everything will work. I had a tweet ready to thank Kamruz, but then I stumbled upon a small issue: 

SXA cannot resolve the tokens outside an SXA site. This is very logical, but as this is the case in your standard values that resulted in errors when I used this in a feature template with standard values. I don't want errors and the solution was simple - just return a standard path instead of null when the result of the query is null.

As a recap, I'll add my version of the code here (but still - credits to Kamruz for the original):
public class TokenedTreelist : Sitecore.Shell.Applications.ContentEditor.TreeList
{
  public override string DataSource
  {
    get
    {
      var data = base.DataSource;
      if (!base.DataSource.StartsWith("query:", StringComparison.OrdinalIgnoreCase))
      {
        return data;
      }

      if (Sitecore.Context.ContentDatabase == null || ItemID == null)
      {
        return null;
      }

      var currentItem = Sitecore.Context.ContentDatabase.GetItem(ItemID);
      Item source = null;
      try
      {
        source = LookupSources.GetItems(currentItem, data).FirstOrDefault();
      }
      catch (Exception ex)
      {
        Log.Error("Treelist field failed to execute query.", ex, this);
      }

      return source == null ? "/sitecore/Content" : source.Paths.FullPath;
    }

    set => base.DataSource = value;
  }
}  
We are actually creating a new field here - fully using the TreeList as a base and overriding the Datasource property. A few minor changes were made to the code from Kamruz - but the most important one is the last line of the getter where we return "/sitecore/content" as default instead of null. 

Don't forget to register this field in the core database as this is a new field. Add an item at /sitecore/system/Field types/List Types and fill your assembly and class information.


Also add it to the field types list in the Sitecore config:
<sitecore>
  <fieldTypes>
    <fieldType patch:after="*[@name='Treelist']" name="Tokened Treelist" type="Sitecore.Data.Fields.MultilistField, Sitecore.Kernel" />
  </fieldTypes>
</sitecore>
You might want to add it to the index configuration as well - in our case that was not needed.

If we select "Tokened Treelist" as field type, we can now use all SXA tokens within our datasource. Such a datasource could look like:
datasource=query:$site/Data/Documents&allowmultipleselection=yes&includetemplatesfordisplay=Document,Document folder&includetemplatesforselection=Document

Conclusion

I don't think Kamruz realised in 2016 when he wrote that post that this would now enable me to use SXA tokens in a Treelist datasource. Sitecore community has proven this strength once more - so I had to share my findings as well to inform you about the extra options this can give us: multisite Treelists with SXA tokens! 

Tuesday, October 13, 2020

Cloning a SXA site (in another folder)

Cloning a Sitecore SXA site

Part 7 already in my series of blog posts related to a multisite project where we wanted to make it as easy as possible to create a new site - with the premise that all the sites can share almost all settings. As you might have read in the previous posts we are really doing this - and keeping it all flexible. It's not because we share (all) things, we cannot configure or overrule some parts if needed.

Anyway - we do have a setup with a shared site and a base "master" site within a tenant (as recommended by SXA). As I did not want these sites to be amongst all the other created sites, I placed those two in a Sites Folder within the tenant. We have (at least) one other Sites Folder where we want to place the actual sites - and we might even want to split those sites over different folders.

We use SXA site cloning to create a new site. This is a very easy and rapid process. The "Clone Site" Powershell (SPE) script that is used can be found by a right click on a site item in the Scripts section. It will ask the name of the new destination site and copy everything from the master site. Note that:
  • it will also copy media items within the sites media folder - really useful if you have a standard folder setup there
  • it will not copy any forms - a new empty folder is created though within Forms
  • it will change all links towards the newly created items

The script works fine and it is a very fast way to create a site. But.. it will create a site next to the master site. Of course, we can move all items after the creation - but it would be faster to just ask a preferred location and created the site there. So that's what I did...

The solution provided here is only tested with one level of site folders - it probably could work with a tree of those folders with a few more adjustments though. 

The Powershell scripts to clone a site

Show-CloneSiteDialog

First script to take care of is the dialog script. We want the user that clones a site to be able to choose the site folder where the destination site should be created. I created a copy of the ootb script "/sitecore/system/Modules/PowerShell/Script Library/SXA/SXA - Multisite/Functions/Site cloning/Show-CloneSiteDialog" and adjusted that one (you could also opt to change the original one). The changes to the original script are not that big so I will not put the entire script here to keep focus.  

In the beginning of the script process the dialogParameters are defined. We add one more parameter there (siteLocation):
$dialogParameters.Add(@{ Name = "siteLocation"; 
  Title = [Sitecore.Globalization.Translate]::Text([Sitecore.XA.Foundation.Multisite.Texts]::Path); 
  Editor = "droplist"; Source="query:$($SourceSite.Parent.Paths.Path)/ancestor-or-self::*[@@templatename='Tenant']/*[@@templatename='Site Folder']"; 
  Tab = [Sitecore.Globalization.Translate]::Text([Sitecore.XA.Foundation.Multisite.Texts]::CloneSiteDialogGeneralTab); Mandatory=$true })  | Out-Null  
I am using a droplist here with a datasource query that finds my tenant and takes the children of type Site Folder. (note we are only supporting one level here - this customization would need to be changed to support a tree).
To keep it simple I used an existing text as label. The selection is marked as mandatory to make sure a choice is made.

The next (and final) change to this script is all the way at the bottom where the results are captured:
@{
    siteName              = $siteName
    siteDefinitionmapping = $mapping
    parent                = $siteLocation
}
We add a "parent" here that will contain the chosen location of the new site.

Clone-Site

Next script to adjust is the main one: "/sitecore/system/Modules/PowerShell/Script Library/SXA/SXA - Multisite/Content Editor/Context Menu/Clone Site". This is the script that gets called from the context menu and we need to adjust it so we can use the parent from our dialog:
$ctx = Get-Item .
    
Import-Function Copy-Site
Import-Function Show-CloneSiteInParentDialog
    
$dialogResult = Show-CloneSiteInParentDialog $ctx
$cloneSiteName = $dialogResult.siteName
$mapping = $dialogResult.siteDefinitionmapping

#custom    
 $parent = $dialogResult.parent
 if (!$parent) {
     $parent = $ctx.Parent
  }
#endcustom

$destinationSite = Copy-Site $ctx $parent $cloneSiteName $mapping
$destinationSiteID = $destinationSite.ID.ToString()
$host.PrivateData.CloseMessages.Add("item:load(id=$destinationSiteID)")

Run-SiteManager
I am just showing the main part here (within the "try") to keep it clear. So we changed the call to Show-CloneSite to the one we created, we fetch the parent (with a fallback scenario although that is probably not needed) and pass it to the Copy-Site function.

And that's it. Well, that's what we thought so we tested this. The site was indeed created in the correct folder. So was the forms folder. But the media folder was still next to our master. 

Copy-Site

Let's take a look at the "/sitecore/system/Modules/PowerShell/Script Library/SXA/SXA - Multisite/Functions/Site cloning/Copy-Site" script. The site creation was fine with our new parent. We noticed that creating the Forms folder was done as it would be for a new site and that script uses the paths to determine where to create a new (empty) folder - this works fine in our version as well.

So we need to take a look at the copy process for the media folder. Somewhere in the middle of the Copy-Site script you'll find the media part and let's customize that a bit:
if ($Site.SiteMediaLibrary) {
  $SiteMediaLibrary = $Site.Database.GetItem($Site.SiteMediaLibrary) | Wrap-Item

#custom
  if ($SiteMediaLibrary.Parent.Name -ne $destinationSite.parent.Name) {
    $SiteMediaRoot = $ctx.Database.GetItem($SiteMediaLibrary.Parent.Paths.Path.Replace($($SiteMediaLibrary.Parent.Name),$($destinationSite.parent.Name)))
    if (!$SiteMediaRoot) {
      $SiteMediaRoot = $ctx.Database.GetItem($ctx.SiteMediaLibrary).Parent
    }
  }
  else {
    $SiteMediaRoot = $SiteMediaLibrary.Parent
  }
#end custom

  $NewSiteMediaLibrary = Copy-RootAndFixReference $SiteMediaLibrary $SiteMediaRoot $destinationSite.Name

  Set-NewLinkReference $Site $destinationSite $SiteMediaLibrary.Paths.Path $NewSiteMediaLibrary.Paths.Path
  $destinationSite.SiteMediaLibrary = $NewSiteMediaLibrary.ID
}
Instead of copying the media tree to the parent of the master media tree, we use string replacement and the paths to determine the location we want. Note that we do need a folder structure corresponding to the Site Folders structure, otherwise this part will fail. We could expand the script to create the necessary folders if needed.


Conclusion

With that last change we managed it. Our new site cloning script will ask us where to create the destination site - of course within the same tenant and in our one-level Site Folder structure. Combined with all the stuff from my previous posts - and the ootb features - creating a site is becoming really fast.